據(jù)集加載實(shí)戰(zhàn):從內(nèi)置到自定義的完整指南)
搞NLP的人應(yīng)該都體會(huì)過一種割裂感模型代碼寫得再順手一遇到數(shù)據(jù)格式不一樣立刻被打回原形。要么是不同腳本之間的字段對(duì)不上要么是數(shù)據(jù)集太大一次性讀進(jìn)來內(nèi)存直接爆掉還得自己寫一堆生成器、緩存邏輯。PaddleNLP 好就好在把“加載數(shù)據(jù)集”這件瑣事做成了正經(jīng) API內(nèi)置數(shù)據(jù)集一條命令拿到手自定義數(shù)據(jù)集也有清晰的路子可走不用再回到“一個(gè)任務(wù)一個(gè)解析腳本”的野路子時(shí)代。這篇文章我按自己的實(shí)操經(jīng)驗(yàn)來寫圍繞 PaddleNLP 加載數(shù)據(jù)集和自定義數(shù)據(jù)集展開把整體設(shè)計(jì)思路、核心 API 細(xì)節(jié)、從零自定義的完整流程、以及各種坑挨個(gè)拆給你看。適合理剛?cè)胧?PaddleNLP 的新手也適合被數(shù)據(jù)集加載折騰過、想找個(gè)規(guī)范方案的成熟開發(fā)者參考。下面直接進(jìn)入正題。1. 整體設(shè)計(jì)思路拆解為什么需要一套統(tǒng)一的數(shù)據(jù)集體系1.1 數(shù)據(jù)加載不該是每個(gè)項(xiàng)目各寫各的先聊一個(gè)很普遍的現(xiàn)象很多人寫 NLP 項(xiàng)目第一步就是從某個(gè)目錄下讀 txt、讀 CSV隨手 for 循環(huán)解析然后塞進(jìn)列表繼續(xù)往下走。小 demo 沒問題一旦數(shù)據(jù)多了、任務(wù)多了麻煩就來了——每個(gè)腳本的數(shù)據(jù)格式、字段名、tokenize 時(shí)機(jī)全都不一樣換模型的時(shí)候代碼大改換數(shù)據(jù)集的時(shí)候代碼又要大改。PaddleNLP 的思路其實(shí)是把“數(shù)據(jù)加載”這件事抽象成一層獨(dú)立結(jié)構(gòu)不管你的數(shù)據(jù)是內(nèi)置的公開數(shù)據(jù)集還是自己搜集的私有文件最終都統(tǒng)一成兩種數(shù)據(jù)集對(duì)象——MapDataset和IterDataset。前者支持隨機(jī)訪問、亂序、重復(fù)讀取適合量級(jí)適中的場(chǎng)景后者是流式迭代每次只加載一條樣本適合超大文件或者在線讀取的場(chǎng)景。理解了這層抽象你就知道為什么 PaddleNLP 能把數(shù)據(jù)加載做得“省心”了因?yàn)樯蠈幽P陀?xùn)練邏輯只認(rèn)數(shù)據(jù)集對(duì)象不關(guān)心你的數(shù)據(jù)來自 HuggingFace 風(fēng)格的內(nèi)置 hub還是來自本地一個(gè)毫不起眼的 text 文件。1.2 內(nèi)置加載與自定義加載不是兩套邏輯很多人以為內(nèi)置數(shù)據(jù)集和自定義數(shù)據(jù)集是兩種 API其實(shí)不對(duì)。PaddleNLP 的設(shè)計(jì)是同一棵樹的兩種分支內(nèi)置數(shù)據(jù)集只是一些預(yù)置好的“數(shù)據(jù)描述腳本”它們最終也會(huì)被解析成標(biāo)準(zhǔn)數(shù)據(jù)集對(duì)象自定義數(shù)據(jù)集則是你自己寫一個(gè)差不多的描述腳本或者直接用讀取函數(shù)構(gòu)造數(shù)據(jù)集對(duì)象。也就是說學(xué)一次 API 的用法內(nèi)置和自定義都能吃透。我見過不少朋友上來就去找“自定義數(shù)據(jù)集專用教程”其實(shí)先把load_dataset的返回類型和DatasetBuilder的工作方式搞清楚比背幾個(gè) API 參數(shù)管用得多。1.3 關(guān)鍵MapDataset 與 IterDataset 的取舍簡單說MapDataset支持len()、下標(biāo)索引、map()、filter()。它在內(nèi)存里維護(hù)完整的樣本列表隨機(jī)打亂方便小中型數(shù)據(jù)集的默認(rèn)選擇。IterDataset迭代式讀取內(nèi)存友好。你可以在_read里一個(gè)yield一個(gè)樣本訓(xùn)練時(shí)一個(gè) batch 一個(gè) batch 地消費(fèi)適合上千萬條的大規(guī)模數(shù)據(jù)。如果你只是做實(shí)驗(yàn)階段的模型驗(yàn)證優(yōu)先MapDataset線上全量訓(xùn)練、單機(jī)內(nèi)存緊張時(shí)再切IterDataset。后面自定義部分我會(huì)演示兩種寫法。2. 核心細(xì)節(jié)解析與實(shí)操要點(diǎn)load_dataset 和 DatasetBuilder 的里子2.1 load_dataset 內(nèi)置數(shù)據(jù)集一句話到底發(fā)生了什么PaddleNLP 的load_dataset可以直接加載一系列公開數(shù)據(jù)集拿中文情感分類數(shù)據(jù)集chnsenticorp來說from paddlenlp.datasets import load_dataset train_ds, dev_ds load_dataset( chnsenticorp, splits[train, dev] ) print(train_ds[0])輸出大概會(huì)是這樣{text: 酒店不錯(cuò)環(huán)境很好服務(wù)周到, label: 1}底層發(fā)生的事大致是PaddleNLP 根據(jù)數(shù)據(jù)集名稱定位到對(duì)應(yīng)內(nèi)置實(shí)現(xiàn)然后調(diào)用類似DatasetBuilder的讀取邏輯把每條樣本逐步解析成字典再統(tǒng)一打包成數(shù)據(jù)集對(duì)象返回。這里有個(gè)值得注意的細(xì)節(jié)splits參數(shù)控制返回幾個(gè)劃分。你可以只取train也可以同時(shí)拿train和dev。有些數(shù)據(jù)集還支持test劃分具體以對(duì)應(yīng)數(shù)據(jù)集文檔為準(zhǔn)。返回的是一個(gè)列表還是多個(gè)變量取決于你傳了幾個(gè) split 字符串。2.2 DatasetBuilder 的兩個(gè)鉤子方法想真正玩轉(zhuǎn)自定義建議理解DatasetBuilder的機(jī)制。它是 PaddleNLP 為數(shù)據(jù)集讀取定義的一套規(guī)范模板核心就兩個(gè)抽象方法_get_data決定數(shù)據(jù)從哪來??梢允潜镜芈窂揭部梢允且粋€(gè)遠(yuǎn)程地址下載后的路徑。返回一個(gè)路徑或路徑列表。_read逐條產(chǎn)出樣本。這是一個(gè)生成器函數(shù)每次yield一個(gè)dict字典的 key 就是樣本字段名。當(dāng)你在自定義類里實(shí)現(xiàn)這兩個(gè)方法后調(diào)用builder.read(data_path, modetrain)就能得到標(biāo)準(zhǔn)數(shù)據(jù)集對(duì)象。整個(gè)過程非常像“把數(shù)據(jù)讀取規(guī)則寫成配置文件”只不過這個(gè)“配置”是用 Python 代碼寫的靈活度滿格。2.3 為什么 _read 必須用 yield而不是 return 列表這是新手最容易踩的一個(gè)認(rèn)知點(diǎn)。_read寫成生成器核心價(jià)值是省內(nèi)存。數(shù)據(jù)量大時(shí)一個(gè)一個(gè)生成樣本訓(xùn)練循環(huán)消費(fèi)一條、釋放一條內(nèi)存始終穩(wěn)定。如果改成一次性返回整個(gè)列表百萬級(jí)樣本可能直接吃掉幾十個(gè) G 內(nèi)存。而且DatasetBuilder內(nèi)部可能還會(huì)根據(jù)返回結(jié)果做統(tǒng)一封裝如果所有樣本都拿到內(nèi)存里就轉(zhuǎn)MapDataset如果只是流式迭代就轉(zhuǎn)IterDataset。所以你在寫自定義邏輯時(shí)盡量保持生成器風(fēng)格能流式就流式別提前把數(shù)據(jù)全讀進(jìn)來。3. 實(shí)操過程與核心環(huán)節(jié)實(shí)現(xiàn)從內(nèi)置到自定義的完整套路3.1 內(nèi)置數(shù)據(jù)集加載實(shí)戰(zhàn)情感分類數(shù)據(jù)一鍵到位先看最順滑的用法。內(nèi)置數(shù)據(jù)集chnsenticorp是中文評(píng)論情感二分類非常適合用來跑通模型驗(yàn)證流程。代碼如下from paddlenlp.datasets import load_dataset train_ds, dev_ds load_dataset( chnsenticorp, splits[train, dev] ) print(len(train_ds)) print(train_ds[0]) print(train_ds[0][text], train_ds[0][label])這一步你就拿到了可以直接喂給模型的訓(xùn)練集和驗(yàn)證集。樣本格式是統(tǒng)一的{text: ..., label: ...}。如果你加載的是英文數(shù)據(jù)集imdb同理train_ds, test_ds load_dataset(imdb, splits[train, test])在動(dòng)手之前建議先打印前幾條樣本確認(rèn)字段名。不同數(shù)據(jù)集字段名可能不一樣有的叫text有的叫sentence直接套字段名在后續(xù)寫 convert 函數(shù)時(shí)會(huì)快很多。3.2 本地文件自定義數(shù)據(jù)集一行一行讀也不丟人假設(shè)你有個(gè)本地 TSV 文件每行由text和label組成用制表符分隔。最直接的自定義方式是用一個(gè)讀取函數(shù)把文件解析成樣本列表再交給MapDatasetfrom paddlenlp.datasets import MapDataset def read_tsv_samples(data_path): samples [] with open(data_path, r, encodingutf-8) as f: for line in f: line line.strip() if not line: continue parts line.split(\t) if len(parts) 2: continue text parts[0] label int(parts[1]) samples.append({text: text, label: label}) return samples train_ds MapDataset(read_tsv_samples(./data/train.txt)) valid_ds MapDataset(read_tsv_samples(./data/dev.txt))別看這個(gè)方法簡單對(duì)于中小規(guī)模數(shù)據(jù)非常穩(wěn)。你不需要了解DatasetBuilder的任何細(xì)節(jié)只要保證每個(gè)樣本是字典就行。3.3 更規(guī)范繼承 DatasetBuilder 做自定義讀取如果希望代碼更工程化或者后面要支持多個(gè)數(shù)據(jù)劃分、緩存下載、字段過濾那么推薦繼承DatasetBuilder。下面給一個(gè)可運(yùn)行的完整示例from paddlenlp.datasets import DatasetBuilder class TsvTextClassification(DatasetBuilder): def _get_data(self, data_path, modetrain, **kwargs): # 這里默認(rèn)數(shù)據(jù)已經(jīng)在本地路徑上 return data_path def _read(self, data_path, modetrain, **kwargs): with open(data_path, r, encodingutf-8) as f: for line in f: line line.strip() if not line: continue text, label line.split(\t) yield {text: text, label: int(label)} builder TsvTextClassification() train_ds builder.read(data_path./data/train.tsv, modetrain) dev_ds builder.read(data_path./data/dev.tsv, modedev) print(len(train_ds)) print(train_ds[0])這個(gè)例子最關(guān)鍵的地方在_read它每次yield一個(gè)字典而不是在函數(shù)內(nèi)部重新組裝一遍數(shù)據(jù)容器。read()方法會(huì)替你做數(shù)據(jù)集轉(zhuǎn)換最后返回給你的就是標(biāo)準(zhǔn)數(shù)據(jù)集對(duì)象可以直接接后續(xù)處理。如果你想做得更通用可以在__init__里存字段名列表然后在_read里動(dòng)態(tài)解析。比如字段名是[sentence, tag]就按順序 split 后用zip生成字典。這樣同一套代碼能解析不同結(jié)構(gòu)的 TSV 文件。3.4 處理 JSON 數(shù)據(jù)“鍵”是你的朋友日常工作中 JSON 格式也不少。下面是一個(gè)解析 JSONL 的示例每行一個(gè) JSON 對(duì)象import json from paddlenlp.datasets import DatasetBuilder class JsonlTextClassification(DatasetBuilder): def _get_data(self, data_path, modetrain, **kwargs): return data_path def _read(self, data_path, modetrain, **kwargs): with open(data_path, r, encodingutf-8) as f: for line in f: line line.strip() if not line: continue items json.loads(line) yield { text: items[text], label: items.get(label, -1) }這里我刻意在label上用了get方法因?yàn)椴皇撬袠颖径家欢◣?biāo)簽。這種寫法在處理無標(biāo)簽測(cè)試集時(shí)非常有用。提示遇到字段名不統(tǒng)一的臟數(shù)據(jù)不要把所有解析邏輯堆在_read里可以拆出一個(gè)小函數(shù)_parse_line(line)方便單測(cè)和復(fù)用。3.5 自定義數(shù)據(jù)集接模型實(shí)操tokenize、batchify、DataLoader 一條龍數(shù)據(jù)集對(duì)象只是第一步真正訓(xùn)練還需要把樣本轉(zhuǎn)換成模型輸入。PaddleNLP 一般這么做首先定義一個(gè)convert_example函數(shù)把text轉(zhuǎn)成input_ids和token_type_idsfrom functools import partial from paddlenlp.transformers import AutoTokenizer tokenizer AutoTokenizer.from_pretrained(ernie-3.0-base-zh) def convert_example(example, tokenizer, max_seq_length128): encoded tokenizer( textexample[text], max_seq_lenmax_seq_length, truncationTrue, paddingFalse ) return { input_ids: encoded[input_ids], token_type_ids: encoded[token_type_ids], label: example[label], } train_ds train_ds.map( partial(convert_example, tokenizertokenizer, max_seq_length128) ) dev_ds dev_ds.map( partial(convert_example, tokenizertokenizer, max_seq_length128) )注意這里的map是MapDataset自帶的轉(zhuǎn)換接口它會(huì)遍歷每個(gè)樣本并應(yīng)用convert_example。用partial傳額外參數(shù)比在函數(shù)內(nèi)部反復(fù)from_pretrained高效得多。接下來是 batch 化。需要把不同長度的input_ids拼到一個(gè) batch 里所以要用到 PaddleNLP 自帶的Pad和Stackimport paddle from paddlenlp.data import Pad, Stack, Tuple batchify_fn lambda samples, fnTuple( Pad(pad_valtokenizer.pad_token_id), Pad(pad_valtokenizer.pad_token_type_id), Stack(dtypeint64) ): fn(samples) train_loader paddle.io.DataLoader( datasettrain_ds, batch_size32, shuffleTrue, collate_fnbatchify_fn, return_listTrue ) dev_loader paddle.io.DataLoader( datasetdev_ds, batch_size32, shuffleFalse, collate_fnbatchify_fn, return_listTrue )Tuple會(huì)把三個(gè)字段分別處理input_ids用 Pad 補(bǔ)齊到 batch 內(nèi)最大長度token_type_ids同樣補(bǔ)齊label用 Stack 堆疊成向量。這一步如果不處理好模型前向計(jì)算時(shí)維度一定會(huì)崩。到這里從“一個(gè)本地 TSV 文件”到“可以訓(xùn)模型的 DataLoader”整條鏈路就走通了。你會(huì)發(fā)現(xiàn)中間幾乎沒寫什么“和具體模型綁定”的代碼換模型只需要換 tokenizer 和模型結(jié)構(gòu)數(shù)據(jù)處理這塊完全不用動(dòng)。4. 常見問題與排查技巧實(shí)錄4.1 報(bào)錯(cuò)路徑不存在或格式解析失敗最常見的原因有兩個(gè)一是文件編碼問題Windows 下另存為 UTF-8 帶 BOM 會(huì)導(dǎo)致首行出現(xiàn)\ufeff打印第一條樣本時(shí)會(huì)看到第一行文本前有個(gè)奇怪字符。解決辦法很簡單讀文件時(shí)統(tǒng)一用encodingutf-8-sigwith open(path, r, encodingutf-8-sig) as f: ...二是文件不是嚴(yán)格的兩列。比如 CSV 里字段之間用逗號(hào)分隔但你按split(\t)解析得到的結(jié)果只有一列。建議先head -n 5看一眼文件結(jié)構(gòu)再?zèng)Q定分隔符。4.2 字段名對(duì)不上明明打印出來了卻寫不進(jìn) convert 函數(shù)見過太多次這種場(chǎng)景樣本打印出來是{sentence: xxx, sentiment: 1}但你的convert_example卻在取example[text]于是跑起來就 KeyError。這里真沒啥好技巧打印 肉眼對(duì)齊字段名是最可靠的。要么就寫一個(gè)容錯(cuò)函數(shù)def get_text(example): for key in (text, sentence, content, review): if key in example: return example[key] raise ValueError(fUnknown text field: {list(example.keys())})多字段數(shù)據(jù)集特別實(shí)用。4.3 數(shù)據(jù)量大內(nèi)存直接爆了怎么辦如果MapDataset加載上百萬條樣本吃不消考慮切成IterDataset。自定義讀取函數(shù)保持yield生成器風(fēng)格from paddlenlp.datasets import IterDataset def iter_samples(path): with open(path, r, encodingutf-8) as f: for line in f: ... yield {text: text, label: label} train_ds IterDataset(iter_samples(./big_train.tsv))IterDataset不維護(hù)全量樣本列表所以不能用len()精確判斷長度也不能隨機(jī)打亂。如果確實(shí)需要 shuffle可以先把索引打亂再通過索引去讀或者使用paddle.io.DataLoader的shuffleTrue它內(nèi)部會(huì)在迭代批次上做部分打亂比一次性全部 shuffle 內(nèi)存友好得多。4.4 batchify 時(shí) Padding 值選錯(cuò)這個(gè)問題非常隱蔽。如果input_ids的 padding 值填成了 0而模型詞表里 0 恰好是[UNK]或其他有效 token會(huì)輕微干擾訓(xùn)練。正確做法是用 tokenizer 的pad_token_idPad(pad_valtokenizer.pad_token_id)同時(shí)token_type_ids的 padding 值也對(duì)應(yīng) tokenizer 的pad_token_type_id不要圖省事直接寫 0。4.5 踩坑速查表問題現(xiàn)象常見原因排查順序首條樣本帶\ufeff文件有 BOM改用utf-8-sig編碼打印出來的字段和代碼不符數(shù)據(jù)集字段名不同先 print 樣本再寫 convert加載大文件卡死或占內(nèi)存巨大全量讀入改用 IterDataset 流式處理batch 后維度不一致collate_fn 沒寫對(duì)檢查 Pad、Stack 順序tokenizer 輸出不齊max_seq_len 設(shè)置不一致統(tǒng)一 truncation 和 padding 參數(shù)label 轉(zhuǎn)成 float 出錯(cuò)標(biāo)簽是字符串在 _read 里先 int()4.6 一個(gè)項(xiàng)目里同時(shí)加載多個(gè)來源數(shù)據(jù)怎么辦實(shí)際業(yè)務(wù)中常常要混合多個(gè)文件一部分是內(nèi)置數(shù)據(jù)集一部分是本地標(biāo)注數(shù)據(jù)。我的建議是分別加載成數(shù)據(jù)集對(duì)象后用MapDataset的列表對(duì)象直接拼接base_ds load_dataset(chnsenticorp, splits[train]) extra_ds MapDataset(read_tsv_samples(./extra.txt)) merged_ds MapDataset(list(base_ds) list(extra_ds))做這種拼接前要注意字段統(tǒng)一先各自打印第一條確保 key 完全一致再合。如果有字段不一致可以寫一層unify轉(zhuǎn)換函數(shù)統(tǒng)一字段名。4.7 加載預(yù)處理后的內(nèi)存數(shù)據(jù)不落盤能行嗎當(dāng)然能。MapDataset直接吃樣本列表所以你可以把數(shù)據(jù)庫里的數(shù)據(jù)查出來后直接構(gòu)造rows query_from_database(select text, label from reviews limit 10000) train_ds MapDataset([{text: r[0], label: int(r[1])} for r in rows])這樣能省去中間生成文件的環(huán)節(jié)對(duì)實(shí)時(shí)實(shí)驗(yàn)和快速驗(yàn)證特別友好。不過樣本量超過十萬級(jí)時(shí)我還是建議落盤后再流式讀取避免查詢結(jié)果列表本身占掉太多內(nèi)存。關(guān)于一個(gè)高頻追問是不是所有自定義數(shù)據(jù)集都非寫 DatasetBuilder 不可不是。我個(gè)人實(shí)際使用中90% 的簡單場(chǎng)景用MapDataset 讀取函數(shù)就夠了代碼直觀、易調(diào)試。只有三種情況我會(huì)認(rèn)真寫DatasetBuilder數(shù)據(jù)集內(nèi)部有復(fù)雜的下載邏輯需要先下載解壓再讀取多個(gè)數(shù)據(jù)劃分使用同一套讀取規(guī)則寫一次反復(fù)復(fù)用想讓自己團(tuán)隊(duì)的代碼風(fēng)格統(tǒng)一所有數(shù)據(jù)集的加載都在一個(gè)類體系里管理。如果你只是臨時(shí)跑個(gè)腳本別被“自定義數(shù)據(jù)集 必須寫類”嚇到。掌握最樸素的方式反而更快。我個(gè)人在小規(guī)模實(shí)驗(yàn)里始終保留一套“毒奶測(cè)試”加載完數(shù)據(jù)集先打印 3 條樣本再跑一個(gè) batch 看形狀最后再過一次模型的 forward。這套三連招能避開 80% 的數(shù)據(jù)集坑。以后再碰到 PaddleNLP 數(shù)據(jù)集的幺蛾子先想想是不是格式問題、字段問題還是內(nèi)存策略問題對(duì)照上面的排查表通常都能快速定位。