圍繞 transformers 構建現代 NLP 開發環境

張哥說技術發表於2023-11-24

圍繞 transformers 構建現代 NLP 開發環境


來源:阿里技術

這是2023年的第85篇文章

( 本文閱讀時間:15分鐘 )




01



Intro

最近在 review 和重構團隊的 NLP 煉丹基礎設施,並基於 tranformers 庫做了重新設計,本文將從“樣本處理”,“模型開發”,“實驗管理”,“工具鏈及視覺化“ 幾個角度介紹這項工作,並簡單聊聊個人對“軟體2.0”的看法。


02



樣本處理

核心思路:函式式,流式,組合式,batch 做多路融合,對 datasets 相容

雖然隨機讀取的資料集用起來最方便,但是在大部分實際應用場景中,隨機讀取往往難以實現。不過,我們能構造流式讀取的介面,例如:

  • MaxCompute(ODPS) :無法透過行號快速讀取資料,但是有 Tunnel 介面支援從某個下標開始順序讀取資料。
  • 檔案系統:包括本地檔案,HDFS,以及OSS等物件儲存。本地檔案雖然能用 lseek() 等函式快速跳轉到某個位置(且該操作通常為 O(1)),但是如果每條樣本位元組數不一樣,封裝為隨機讀取還是非常複雜,但是做成流式讀取就很容易。其他雲上的儲存介質更是如此。
  • 訊息佇列:例如 MetaQ,天然流式的資料,可以主動拉取,也可以以訂閱的方式封裝流式讀取的介面。


在我們設計的資料載入框架中,可以用以下程式碼來實現 ”分別從兩個 ODPS 表裡讀取正樣本和負樣本,用 func 函式處理後,在 batch 內以 1:1 的方式混合,正負樣本分別用兩個執行緒並行讀取“。

positive = Threaded(Map(func, ODPS(access_id, access_key, project,                            positive_sample_table_name,                            read_once=False)))negative = Threaded(Map(func, ODPS(access_id, access_key, project,                            negative_sample_table_name,                            read_once=False)))combined = Combine([positive, negative], sample_weight=[1.0, 1.0])


返回的 combined 變數是一個普通的 python generator,可以直接從中獲取資料,也可以將其傳入 huggingface 的 datasets 模組。該方案優勢很明顯:靈活可擴充套件,懶載入節約資源。











# 直接讀取資料for data in combined:  print(data)
# 使用 huggingface datasets 模組# 之後可以直接用在 transformers.Trainer 類中參與訓練import datasetstrain_dataset = datasets.IterableDataset.from_generator(combined,  gen_kwargs={"ranks": [0,1,2,3], "world_size": 4} # 支援分散式訓練)

2.2 技術問題:對分散式訓練的支援

datasets.IterableDataset.from_generator 函式,可以額外傳入一個名為 gen_kwargs 的 dict 型別引數,若某個 value 型別是 list,則會在 dataloader num_workers 數大於 1 的時候,自動進行分片,將分片後的 list 傳給底層的 generator[1]。huggingface 在開發模組時,經常使用這種“隱含的呼叫規約”,在使用者輸入和輸出滿足某些條件時,觸發特定的功能,這一點在他們開發的其他模組中也有所展現。

在我們的設計中,所有載入資料的 generator 都預設接受 ranksworld_size 兩個額外引數,其中 ranks 為 list 型別,代表該 generator 處理的分片列表,world_size 時分片的總數,在實現載入邏輯時,根據這兩個引數,讀取對應分片的資料。







def _ODPS(access_id, access_key, project, table_name, partition_spec, read_once, retry,          endpoint, ranks=None, world_size=None):    # 載入 ranks + world_size 對應分片資料,實現略(計算讀取 range 後,使用 PyODPS 載入資料)
def ODPS(access_id, access_key, project, table_name, partition_spec=None, read_once=True, retry=True, endpoint="):    return partial(_ODPS, access_id, access_key, project, table_name, partition_spec, read_once, retry, endpoint)

注意,這裡使用了partial返回了一個原始函式“科裡化”後的版本,這是使 generator 可組合的關鍵設計。


03



模型開發

核心思路:
  1. 繼承 PreTrainedModel,PreTrainedCofig,PreTrainedTokenizer 基類,與 transformers 體系打通。
  2. 透過 mixin / monkey patching 方式,擴充現有框架功能,例如對 OSS 模型載入/儲存的支援。

    透過繼承 PreTrainedModel,PreTrainedCofig,PreTrainedTokenizer 三件套,模型就能使用 transformers 框架的一系列基礎設施,從而實現如下效果:



















    # 載入我們專案組開發的分類模型(多工層次分類)model = BertForMultiTaskHierarchicalClassification.from_pretrained("./local_dir")# or 從 OSS 直接載入model = BertForMultiTaskHierarchicalClassification.from_pretrained("oss://model_remote_dir")# 儲存模型model.save_pretrained("./local_dir")model.save_pretrained("oss://model_remote_dir")# 載入我們使用 C++ 開發的 tokenizertokenizer = ShieldTokenizer.from_pretrained("oss://model_remote_dir")
    # 使用 AutoClass 實現相同功能,不需要指定特定的模型類名,由框架自動推斷model = AutoModel.from_pretrained("oss://model_remote_dir")tokenizer = AutoTokenizer.from_pretrained("oss://model_remote_dir")
    # 擴充套件 transformers 預設的 pipeline# 增加 multitask-hierarchical-classification 任務pipe = pipeline("multitask-hierarchical-classification", model=model, tokenizer=tokenizer)print(pipe("測試文字"))


    可以看出,我們自研的模型使用方式和 transformers 上的開源模型別無二致,甚至還能直接從 OSS 上載入模型,極大降低了模型的使用學習成本。

    3.1 如何支援集團內的儲存介質

    為了使 transformers 框架中的物件支援 OSS 儲存,我們使用了 mixin 的方式進行了邏輯改寫,讓所有自研的新模型都繼承 OSSRemoteModelMixin,對於 AutoModel,則直接覆蓋他們的 from_pretrained 方法。



















































    class OSSRemoteModelMixin(object):    """    支援使用者在 from_pretrained 和 save_pretrained 時按照 oss://path 的格式指定路徑 (bucket, ak, sk 需要在環境變數中指定, 見 util.oss_util 類)    可以用於所有包含 from_pretrained 和 save_pretrained 方法的類 (config or tokenizer or model)    """    @classmethod    def from_pretrained(cls, pretrained_model_name_or_path: Optional[Union[str, os.PathLike]], *model_args, **kwargs):        pretrained_model_name_or_path = convert_oss_to_local_path(cls, pretrained_model_name_or_path,             kwargs.get('cache_dir', risk_shield.CACHE_DIR))        return super(OSSRemoteModelMixin, cls).from_pretrained(            pretrained_model_name_or_path, *model_args, **kwargs)
       def save_pretrained(        self,        save_directory: Union[str, os.PathLike],        *args,        **kwargs    ):        prefix = "oss://"        oss_path = save_directory        if save_directory.startswith(prefix):            # save to a temp dir            # .......      # 將檔案複製到 OSS,實現略            return res        else:            res = super(OSSRemoteModelMixin, self).save_pretrained(save_directory, *args, **kwargs)
    # 讓模型繼承自 OSSRemoteModelMixin 就會自動獲得 OSS 存取的能力
    class BertForMultiTaskHierarchicalClassification(  OSSRemoteModelMixin, BertPreTrainedModel):    config_class = BertForMultiTaskHierarchicalClassificationConfig
       def __init__(self, config:BertForMultiTaskHierarchicalClassificationConfig):       # .....
    # 對於 AutoModel,則直接覆蓋他們的 from_pretrained 方法。
    def patch_auto_class(cls):    """    讓 AutoClass 支援 OSS 路徑    """    old_from_pretrained = cls.from_pretrained    def new_from_pretrained(cls, pretrained_model_name_or_path: Optional[Union[str, os.PathLike]], *model_args, **kwargs):        pretrained_model_name_or_path = \            convert_oss_to_local_path(cls, pretrained_model_name_or_path,            kwargs.get('cache_dir', risk_shield.CACHE_DIR))        return old_from_pretrained(pretrained_model_name_or_path, *model_args, **kwargs)    cls.from_pretrained = classmethod(new_from_pretrained)


    同樣,讓框架支援 HDFS,ODPS Volumn 等其他儲存介質,也可採用相同的方案。
    3.2 如何使用自己的 Tokenizer

    並不是所有業務都適合直接使用 BertTokenizer,例如,對於我們所在的業務,採用一種融合了外部知識資訊的特殊方式進行 tokenize,用來提升模型效果。

    而且,我們的預測服務是 C++ 程式碼,為了保證預測和訓練時的 tokenize 邏輯完全一致,我們基於 ctypes,用同一套 C++ 程式碼實現 python tokenize 的底層邏輯,並在上層做好 PreTrainedTokenizer 的介面相容。

    為了實現一個相容 PreTrainedTokenizer 的 C++ tokenizer,我們重新實現了以下介面,有類似需求的同學可以參考。

    類變數 vocab_files_names__getstate__, __setstate__,解決 C++ 物件的 pickle 問題_convert_token_to_id_convert_id_to_tokenconvert_ids_to_tokens__setattr__vocab_sizeget_vocabtokenizesave_vocabulary_pad 對 tokenize 後的結果進行 pad,主要用在 trainer 裡的 collator_encode_plus 處理單條文字_batch_encode_plus 處理 batch 文字


    使用我們自研的 tokenizer,在單核下比 huggingface 使用 Rust 開發的 BertTokenizerFast 在多核下更快。

    3.3 訓練程式碼

    符合 transformers 標準的模型,可以直接使用框架的 Trainer 完成模型訓練,並透過 data_collator,callback 機制,對訓練過程進行定製。下面結合上面介紹的內容,給出訓練一個完整分類模型的程式碼(忽略不重要的細節)。

























































    def compute_metrics(model, eval_pred):    logits, labels = eval_pred  # 針對層次分類設計的評估指標    metrics = evaluate_multitask_hierarchical_classifier(model, labels)    return metrics
    # 定義模型tokenizer = ShieldTokenizer.from_pretrained("oss://backbone")config = BertForMultiTaskHierarchicalClassificationConfig.from_pretrained("oss://backbone")config.multi_task_config = {  # 這裡的分類樹僅是例子  "main": {    "hierarchical_tree":    ["父類別1", "父類別2",            ["父類別3",        ["父類別3-子類別1", "父類別3-子類別2", "父類別3-子類別3", "父類別3-子類別4"]      ]        ]  }}model = BertForMultiTaskHierarchicalClassification.from_pretrained("./backbone")# 定義訓練資料載入策略positive = Threaded(Map(func, ODPS(access_id, access_key, project,                            positive_sample_table_name,                            read_once=False)))negative = Threaded(Map(func, ODPS(access_id, access_key, project,                            negative_sample_table_name,                            read_once=False)))combined = Combine([positive, negative], sample_weight=[1.0, 1.0])
    train_ds = datasets.IterableDataset.from_generator(combined)training_arg = TrainingArguments(  output_dir="./output",  overwrite_output_dir=True,  num_train_epochs=4,    # ...  # 其他訓練引數    # ...  dataloader_num_workers=2,)trainer = Trainer(  model=model,  args=training_arg,  train_dataset=train_ds,  tokenizer=tokenizer,  eval_dataset=val_ds,  compute_metrics=partial(compute_metrics, model),  # 針對層次分類開發的 collator  data_collator=MultiTaskHierarchicalClassifierCollator(    tokenizer=tokenizer, model=model, max_length=max_length,    task_label_smooth=task_label_smooth  ))# 將實驗指標寫入 tensorboard 並上傳到 OSStrainer.add_callback(OSSTensorboardWriterCallback("experiment/v1/"))trainer.train()


    從程式碼中可以看出,唯一需要改動的,就是 data_collator(多程式組 batch 及樣本後處理) 和 compute_metrics 邏輯,根據不同的任務頭,需要開發特定的 collator,其他都是標準的模版程式碼。使用 Trainer 類後,可以使用 transformers 框架的強大功能,例如對 DDP,deepspeed 等訓練技術的支援,以及對梯度爆炸等問題的 debug 能力。(設定 debug="underflow_overflow")

    需要注意的是,為了讓 Trainer 正常工作,你的模型的返回必須符合以下格式,如果不喜歡這種 “隱含的呼叫規約”,可以做一個 trainer 的子類對邏輯進行更徹底的重寫。

    1. 第一個元素是 loss,trainer 自動最佳化該值。
    2. 後續的元素只能是 python dict / list / tuple / tensor,tensor 第一維的大小必須和 batch size 一致。最理想的情況就是一個二維 logits 矩陣。

    3.4 模型部署

    針對專案中用到的幾種部署形態,重新封裝部署,預測程式碼。



























    import risk_shieldfrom transformers import AutoTokenizer, AutoModelmodel = AutoModel.from_pretrained("oss://model.tar.gz")tokenizer = AutoTokenizer.from_pretrained("oss://model.tar.gz")
    # 匯出 ONNXmodel.export_onnx(output_dir)
    # 或匯出 TensorRTmodel.export_tensorrt(output_dir)
    # 或匯出 Tensorflow(ODPS 部署)model.export_tf_saved_model(output_dir)
    # 匯出切詞器到同一目錄tokenizer.save_pretrained(output_dir)
    #########################
    # 部署時載入對應 pipelinefrom risk_shield import ONNXHierarchicalClassifierPipelinefrom risk_shield import TensorRTHierarchicalClassifierPipelinefrom risk_shield import TFSavedModelHierarchicalClassifierPipeline
    pipe = TensorRTHierarchicalClassifierPipeline(output_dir)result= pipe("測試文字")

    3.5 最小化依賴

    在設計時,我希望整個框架對開發環境的依賴儘可能低,因此只需要 transformers 就能執行,這套“工具集” 本質上是 transformers 的外掛補丁,遵循該庫的所有介面協議,因此,自然是“高內聚”,“低耦合”的,可以選擇使用其中的部分,例如只用分類頭,但是自己寫 trainer,或者只用模型,不用資料載入模組。可以在 GPU 伺服器或者 Mac 筆記本上訓練模型。


    04



    實驗管理

    Trainer 類原生支援 wandb [2]等平臺,對實驗過程中的指標進行檢視&對比&管理,由於這涉及到將實驗資料上傳到外網,我們選擇將實驗指標以 tensorboard 的格式儲存在 OSS 中,上面程式碼的 OSSTensorboardWriterCallback("experiment/v1/") 部分就實現了這一功能。

    此外,我們在開發了一個實時檢視 OSS 上 tensorboard 的命令列工具,一般我們都會在 GPU 伺服器上訓練模型和上報指標,在各自的筆記本上檢視和分析指標,你看,不需要依賴 wandb 這種外部平臺,也能實現類似的指標管理能力。

    1. 命令列工具,實時拉取 OSS 上的 tensorboard

    圍繞 transformers 構建現代 NLP 開發環境

    2. 超參搜尋及指標對比

    圍繞 transformers 構建現代 NLP 開發環境


    05



    工具鏈及視覺化

    結合 gradio 庫,我們對所有模型任務都開發了視覺化工具,在模型訓練中,可以隨時進行效果試用,具體可以參考 gradio 文件,由於我們實現了對 AutoModel 的支援,因此無論模型採用什麼 backbone,只要任務一致,就能使用同一個工具進行檢視分析。

    1. 命令列工具,載入 OSS 上儲存的模型 checkpoint 並開啟瀏覽器頁面

    圍繞 transformers 構建現代 NLP 開發環境

    2. 基於 gradio 開發的 debug 工具(分類模型)

    圍繞 transformers 構建現代 NLP 開發環境

    3. 基於 gradio 開發的 debug 工具(NER 模型)

    圍繞 transformers 構建現代 NLP 開發環境


    06



    “軟體2.0”

    Andrej Karpathy,前特斯拉人工智慧負責人,在 2017 年發表了一篇名為《Software 2.0》 [3]的文章,文中預言了深度學習將逐步替代掉大部分傳統“手工”演算法,而深度學習技術將從某個傳統演算法系統的子模組,演化成為演算法系統的主要構成部分(例如自動駕駛),或者如 Andrej 所說:“抱歉,梯度下降能比你寫出更好的程式碼”。

    圍繞 transformers 構建現代 NLP 開發環境

    他預言,為了實現軟體2.0,需要一整套服務於深度模型開發的工具棧,就像傳統軟體需要 pip,conda 這類包管理器(package manager),GDB 這類 debug 工具,Github 這類開源社群一樣,深度學習也需要模型 debug 工具,模型和資料集的管理器和開源社群。

    但他沒有預言到的是,類transformer 架構在之後的年月裡大放異彩,不僅統一了 NLP 領域,而且正在逐步統一 CV 等其他領域。“預訓練-微調” 成為業界最常見的模型開發正規化。而 huggingface 公司,藉著 transformers 庫的東風,以及圍繞它建設的模型開源社群(huggingface hub)[4],成為當前 NLP 開發事實上的標準,連目前最火的大模型,都選擇在 huggingface 釋出,例如 ChatGLM:

    圍繞 transformers 構建現代 NLP 開發環境

    這些已經有了軟體 2.0 的雛形。

    因此,只有做到與 transformers 框架和介面標準的充分整合,才能真正與開源社群與行業的技術進步接軌,雖然我們無法使用外部的軟體 2.0 工具棧(例如上面提到的 wandb),但是我們可以在團隊內部以軟體 2.0 的方式工作,例如把 OSS 作為一種 huggingface hub 的替代物(?),在本地啟動 gradio 作為 spaces [5]的替代物。使用 OSS 替代 hub 還有一個額外的好處:與中心化的 hub 不同,每個使用者(專案組)可以使用自己私有的 OSS 地址,用 OSS 自帶的 ACL 進行許可權控制。我們在開發環境中,實現了 push/pull 的能力,對於專案協作/複用來說足夠了。









    -- 團隊 A 同學釋出模型到 OSSshield_publish ~/checkpoint_dir WARNING:root:從本地目錄上傳:~/checkpoint_dirxxxx.tar.gz: 100%|███████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 100/100 [01:50<00:00,  1.11s/it]WARNING:root:已釋出OSS(url):
    -- 團隊 B 同學使用 OSS 上“內部開源”的模型AutoModel.from_pretrained("")

    當然,在國內也有大量與 huggingface 類似的平臺,例如由達摩院開發的 modelscope 社群[6] 和百度的 paddlenlp[7]

    隨著軟體 2.0 工具棧的成熟,演算法開發流程將逐步標準化,工程化,流水線化,不僅大量非科班的玩家都能用 LoRA 微調大模型,用 diffusers 生成人物了,甚至連 AI 都能開發軟體[8]了,那麼在未來,“演算法工程師” 這個 title 會變成什麼呢,會呼叫 import transformers 算不算懂 NLP 呢?這道題作為 課後練習,留給同學們進行思考!

    相關資料

    [01]

    [02] 

    [03] 

    [04] 

    [05] 

    [06] 

    [07] 

    [08] 

    來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70024923/viewspace-2996997/,如需轉載,請註明出處,否則將追究法律責任。

    相關文章