端の知識の備忘録

技術メモになりきれない、なにものか達の供養先

句読点のない日本語の文章に句読点を挿入するBERTモデルを作る

概要

Whisperなどで日本語の文字起こしをした後の文章にほとんど句読点が入っておらず、そのまま文字起こし文章を結合すると利用しにくいことがありました。

そこで何らかの手法で句読点を挿入したいと思ったのですが意外と手軽な古典的な手法が見つかりませんでした。このようなタスクはpunctuation restorationと呼ばれるタスクのようです。

ちょっと調べて見つかったのは次のような日本語BERTモデルのMask Fillを逐語的に適応していく方法でした。

qiita.com

この手法でもできなくはないのですが思ったほど精度が良くないこと、さらに処理時間が文章の長さに比例してだんだん無視できないレベルになるので、もう少し効率的な方法がないかと考えました。

実のところChatGPTに適当に「次の句読点の入っていない文章に適当に句読点を挿入してください」という指示を出せばいい感じに句読点を入れてくれるのですが、わざわざこれだけのためにChatGPTを使うのもなぁと思い、BERTモデルを自分で作ってみることにしたというお話。

作成したコード、Weightは下記リポジトリにあります。

huggingface.co

ちょっとした解説

学習データとして次のKaggle データセットを利用しました。夏目漱石の「明暗」の読み上げを行った音声データと文章データが含まれています。このうち文章データのみを利用してBERTモデルを学習しました。

https://www.kaggle.com/datasets/bryanpark/japanese-single-speaker-speech-dataset/data?select=transcript.txt

基本的なコンセプトとしては、句読点がちゃんと入っている文章において、句読点の前の文字であるか否かをラベルとして付与します。

例えば、「今日は、いい天気。」という文章に対して、

[[0,0,1,0,0,0,0],[0,0,0,0,0,0,1]]

のようなラベルを付与し、BERTに対しては「今日はいい天気」という文章を入力として与え、句or読点の前の文字であるか否かを予測させるという流れです。実際にはTokenizerによりスペシャトークンが付与されるので位置がずれますが、基本的な考え方はこのような感じです。

ベースのモデルとしてはtohoku-nlp/bert-base-japanese-char-v3を利用し、出力のhidden layerを取り出して、その後に全結合層を追加したモデルを作ります。

huggingface.co

それぞれのトークンのhidden stateに対し共通のlinear layerを適応し、「、」の前であるか「。」の前であるかを意味する2次元の出力を得る形となり、最終的な出力形状は(num_tokens, 2)となります。

class punctuation_predictor(torch.nn.Module):
    def __init__(self, base_model):
        super().__init__()
        self.base_model = base_model
        self.linear = torch.nn.Linear(768, 2)

    def forward(self, input_ids, attention_mask):
        last_hidden_state = self.base_model(
            input_ids=input_ids, attention_mask=attention_mask
        ).last_hidden_state
        # get last hidden state token by token and apply linear layer
        return self.linear(last_hidden_state)

で、出来上がったものとしては、次のようにprocess_long_textという関数に生で句読点の入っていない日本語を入力すると、適当に句読点を入れてくれるもの。

process_long_text(
        "句読点ありバージョンを書きました句読点があることで僕は逆に読みづらく感じるので句読点無しで書きたいと思います"
    )

先ほどのQiitaの記事で紹介されていた文章で比較してみます。

本実装

句読点ありバージョンを書きました。句読点があることで僕は逆に読みづらく感じるので、句読点無しで書きたいと思います。

学習データは地の文にあまり点が入っていないうえ近代文学なこともあり、結構かけ離れた文章のはずですがそこそこ上手く句読点を入れてくれているように見えます。

自分で使う分には対象の文字起こし文章10000文字くらいをChatGPTに投げて教師データを作成し、専用モデルを学習させることで割と実用的な精度が出ることを確認しています。

今回の文章の量(2000センテンス)であればRTX4090 1枚でだいたい1epoch 45秒, 10epochで10分くらいで学習が終わるので、そこまで時間がかからないのも良いところです。

GPTを使うには文が多すぎる場合などに活用できるモデルというイメージです。

ChatGPT4

句読点ありバージョンを書きました。句読点があることで、僕は逆に読みづらく感じるので、句読点無しで書きたいと思います。

Qiitaで紹介されていた"cl-tohoku/bert-base-japanese-char"のMask Fillを利用する方法

句読点ありバージョンを書きました句読点があることで僕は逆に、読みづらく感じるので、句読点無しで書きたいと思います。

【ベンチマーク編】 Radeon Instinct MI50を買って機械学習用に使ってみる【ROCm】

まえがき

ROCmを試すためにRadeon Instinct MI50を買ってみて、PyTorchで使えるようにセットアップをしたのが前回。

hashicco.hatenablog.com

今回は取ったベンチマークの結果をご紹介!

まとめ

  • ROCmは本当にほぼコード変更無しでCUDA用のTensorFlow、PyTorch、Transformerのコードが動く。素晴らしい。
  • 1GPUであればMI50のほうがP40よりも速い。特にFP16を利用する場合は4割くらい早く、結構実用的。
  • 2GPUだとあまりまともな速度が出ない。処理速度に波があるような挙動を示すため、Gradientの集約に時間がかかっているのかも。
    • Infinity fabric connectorをつけるとか、PCIe4であればもう少しマシになるのかも。
    • 2GPUをうまく使うならDDPでトレーニングするよりも、別プロセスで別のモデルを同時に2つトレーニングするように使うほうが実用的と思われる。
  • SDXL1.0でも512x512で7.3 sec/枚(FP16)の画像生成速度と、P40の10.9 sec/枚よりも4割以上速い。画像生成で遊ぶならMI50は悪くないと思う。

ということで、MI50は結構使えるGPUと言える。ただ、2GPUでDDPトレーニングをするような場合はあまり速度が出ないので買うなら1枚がオススメ。

P40と比べると、MI50のほうが速度は速いがVRAMが少ない。LLMで遊ぶ用ならメモリ量が第一なのでP40、Diffusion系の画像生成なら必要VRAMも比較的少ないモデルも多いし原理的に計算が重いと思うのでMI50のが良いかもしれません。

ROCm起因のトラブルもないわけではないのでちょっと玄人向けかもしれませんが、性能も3万円とは思えないほどなので、MI50は機械学習で遊ぶ用としてお家に1枚あっても損はないGPUかもしれない。

VRAM 32GB版が安く買えるなら間違いなくコスパ最強と言えると思うが、入手しやすい16GBに関しては必要なVRAM量を考えてP40とMI50のどちらか選ぶのが悩ましいところである。

ちなみに、Redditに「MI50機械学習用にどうよ?」的なスレッドもあったので、購入検討時にはこちらも確認するといいだろう(私は読まずに買ってしまいましたが)

https://www.reddit.com/r/LocalLLaMA/comments/1b5ie1t/interesting_cheap_gpu_option_instinct_mi50/

P40とMI50のスペック

www.techpowerup.com

www.techpowerup.com

ベンチマーク

行ったベンチマークは以下の通り。P40に関しても新しくベンチを取り直し、なるべく公正な比較になるようにしています。

  • FP32 画像分類モデルトレーニング(transformers pytorch exampleのrun_image_classification.py)
  • FP16 言語モデルレーニング(transformers pytorch exampleのrun_clm.py)
  • SDXL1.0 画像生成

環境

P40, MI50

  • CPU: EPYC 7551P 32Cores
  • MEM: DDR4 2133 256GB
  • M/B: H11SSL-i
  • SSD: SATA 1TB
  • OS: Ubuntu22.04

RTX4090

  • CPU: R9 7950X3D 16Cores
  • MEM: DDR5 4800 64GB
  • M/B: MSI X670-P WIFI
  • SSD: M.2 PCIe4 2TB
  • OS: Win11 Pro(WSL2 Ubuntu 22.04)

FP32 画像分類モデルトレーニン

transformersをGitHubからクローンしてくることで利用可能なexamplesを実行します。とりあえずROCmでも問題なく実行できました。

https://github.com/huggingface/transformers/blob/main/examples/pytorch/image-classification/run_image_classification.py

GPU train_samples_per_second eval_samples_per_second train ratio(MI50x1=1) eval ratio(MI50x1=1)
MI50x1 35.686 90.745 1 1
MI50x2 33.933 69.598 0.9509 0.7000
P40x1 31.508 76.755 0.8829 0.8458
P40x2 37.566 74.594 1.0532 0.8219
RTX4090 101.406 197.71 2.8416 2.1787

結果、MI50は1枚のほうが速いという結果に。

1枚勝負ならP40よりもTrainでもEvalでも速いことがわかった。P40x2のTrainでは流石に負けるがその差は僅か。

FP16 言語モデルレーニン

使ったExampleは次のURLのもの。VRAM量の関係からBatch sizeを4に落として実行しました。

https://github.com/huggingface/transformers/blob/main/examples/pytorch/language-modeling/run_clm.py

GPU train_samples_per_second eval_samples_per_second train ratio(MI50x1=1) eval ratio(MI50x1=1)
MI50x1 7.015 19.792 1 1
MI50x2 5.595 11.597 0.7976 0.5859
P40x1 4.582 12.63 0.6532 0.6381
P40x2 6.485 12.787 0.9244 0.6461
RTX4090 19.049 52.211 2.7155 2.6380

結果、1枚のMI50がP40x2よりも速いことがわかった。

レーニングのベンチマーク結果からは、少なくともMI50 1枚は同価格帯のP40よりも性能が高く、VRAM量を気にしなければコスパが高いと言えるだろう。

なんなら30万するRTX4090の1/3~1/2くらいの性能は出ているので、コンペなどでも意外と使えなくもないかもしれない。

SDXL1.0 画像生成

automatic1111のWebUIから、sd_xl_base_1.0による512x512の画像を1枚生成するときの速度を比較。5回生成したときの実行時間の平均を取りました。

P40におけるセットアップは前回ブログでも紹介した次の記事を参照。

https://pc.watch.impress.co.jp/docs/column/ubuntu/1493536.html

ROCmにおけるセットアップは公式のAMD用ガイドを参考にしました。

https://github.com/AUTOMATIC1111/stable-diffusion-webui/wiki/Install-and-Run-on-AMD-GPUs#running-natively

基本的には前回のセットアップ編で記載したvenv環境で./webui.sh --precision full --no-half --listenまたは./webui.sh --upcast-sampling --listenのどちらかのコマンドを実行すれば良いです。

  • MI50(--precision full --no-half): 8.4s
  • MI50(--upcast-sampling): 7.3s
  • P40(オプション無し): 10.9s

いずれにおいてもP40よりも早く推論できています。VRAMの使用量は--precision full --no-halfで88%、--upcast-samplingで47%と、SDXLをなんのオプションも付けずに動かす分にはFP32でもメモリは足りています。

よく画像生成用に買われているRTX3060よりも安く、メモリも多く、しかも結構速いということで、冷却の問題を解決できるパワーユーザーならMI50はアリだと思いました

【セットアップ編】Radeon Instinct MI50を買って機械学習用に使ってみる【ROCm】

まえがき

しばらく大きな買い物をしていなかったが、仕事からの現実逃避をしているとふと思う。「ROCm、試してみたいなー」

ということで、ebayを開きめぼしいGPUを探してみたところ、$200以下のお手頃価格で一応ギリギリ最新のROCm 6.0.2にもメンテナンスサポートされているMI50を発見。

送料も考えて2枚まとめて買ってみたので、数回に分けてROCmのインストールから機械学習用の環境構築、以前に買った同価格帯の中古サーバー用GPUの先輩であるNVIDIA P40との比較までを書いていこうと思う。

今回はセットアップ編ということで、取り付けとROCmインストールでrocm-smiが動くところ、PyTorch上でGPUが認識されるまでを書いていく。

hashicco.hatenablog.com

  • P40の記事

hashicco.hatenablog.com

  • 取り付け時のXポストたち

まとめ

  • ebayでMI50 16GBが1枚$190で買えました。2つ買って送料、消費税込でだいたい70,000円くらい

    • MI25も似たようなスペックですがMI50のほうがアーキテクチャが少し新しく(MI25がgfx900, MI50がgfx906)、メモリバス幅が倍なので、大した金額差ではないしMI50を選択しました。
  • 今回冷却用にArcticの新しいファン(S8038-10K)を買いました。こいつが中々のモンスターで、80mmファンで500~10000rpmのPWM制御が可能、MAX102cfmというスペック。

    • 10000rpmだとノイズもやばいXに動画を上げときましたが、見るときは音量注意
    • 5000rpmくらいに抑えると結構な風量の割に音も耐えられる感じなので、ipmitoolを使ってファンコントロールしました。
    • 1個$16ですが、米アマで4個まとめ買いして大体送料込み12000円くらい
  • ROCmのインストールは拍子抜けするほど簡単!コマンド数行でつつがなく終了したので、下手するとNVIDIAのドライバとCUDAインストールよりも簡単だったかもしれない。

  • 買ってしばらくして気づいたのですが、私の買ったMI50はどうやらRadeon VIIのVBIOSが焼かれているようで、PCからはRadeon VIIとして認識されてるっぽい

    • 購入先はebayのcomputewarehouseというショップ。現在MI50のVBIOSをもらえないか問い合わせ中
    • (20240410 追記) computewarehouseの担当者と連絡が取れ、MI50のVBIOS Flasherをいただくことができた。まだ試せていないが、もしうまくいったらなにか書く予定。
    • MI50をRadeonVIIにする改造は結構広く行われているようなのでまあこのまま使ってもいいのだが、FP64の性能が半分になるらしい。
    • オリジナルをそのまま使いたい気持ちもあるので可能ならVBIOSを書き換えたいのですが、MI50のものはどこを探しても発見できず……。
  • マシンとしては前回作成したEPYCマシンのP40を載せ替え、SSDをサラのに交換したものを使ったのだが、M/BのH11SSL-iでのファン制御は結構苦戦した。

    • 結果的にBMCを最新にアップデートすることでipmitoolによるファンの固定値制御がうまくワークするようになった
  • ちょっとしたトラブルとして、GPU2枚のうち1枚、付いていないはずのGPUファンの表示上の速度が過負荷時に異常に下がって報告されてしまい、システム異常を示すと思われる凄まじいビープ音を発する現象が確認された

    • おそらくRadeonVIIのVBIOSが焼かれていることが原因と思われる。
    • 結局rocm-smiでファン回転数を固定値とすることで問題は解消した。

次から詳しい説明です。興味のある方はどうぞ。

カードの取り付け

  • MI50

今回はアメリカからの発送で受取まで約3週間くらいかかりました。

カードの見た目が中々カッコよくていい感じ。P40と並べて撮影してみても、Radeon Instinct MI50のほうがモダンで高級感があります。

  • S8038-10K

80mm, 38mm厚のマッシブなファンです。まるで120mmファンのような102CFM、強力な51mmH2Oというものすごい静圧ファン。

  • ファンを取り付ける

いつも通り段ボールを使って無理くりブラケットを作ります。カードの上部に出っ張りがあり加工が中々難しかった……。

本当は上部にInfinity fabric connectorの繋ぐ場所があるのですが、肝心のコネクタ自体が買える値段で売ってなかったので使いません。

  • 出来上がったもの

前回作ったEPYCマシンに2枚のMI50を刺します。段ボールの見栄えが悪いし下手すると燃えそうなので真似する場合はあくまで自己責任で!

OS準備とファン制御

今回OSはUbuntu 22.04を使います。ROCm 6.0.2は20.04または22.04対応とのこと。

OSインストールについては、いつも通りEtcherにISOを焼いて新しいSSDをにインストールします。

で、あとはROCmをセットアップ……と行きたいところですが、ファン制御をしないことには10000RPM近くでGPUファンが回るのでとても煩くて作業できません!

ということでファン制御の方法を調べてみるとipmitoolというコマンドで制御できるらしい。

hassiweb.gitlab.io

指定する0x30や0x45などのHexコードの意味はよくわかりませんが、とりあえずファンモードをFullにしたうえで、ファン回転数を手動設定すればいいらしい。

GPU用とその他のファンで制御を分けたかったので、本来の使い方とは違いますがFANA, BコネクタにGPU用のS8038-10Kを接続し、他のファンはFAN1~5に接続します。こうすることで、GPUファンはペリフェラル用のファンゾーン(0x01)で制御できるようになります。

# ファンモードの変更。0x01がFullを意味するらしい
sudo ipmitool raw 0x30 0x45 0x01 0x01

# ファン回転数の手動設定。後ろから2つ目のHexがファンゾーン。今回はペリフェラルの方を制御したいので0x01を指定。
# ファンの回転数は一番うしろのHexで指定。0x00~0xFFの64段階で指定できる。今回は約5000RPMをターゲットにするので0x32を指定
sudo ipmitool raw 0x30 0x70 0x66 0x01 0x01 0x32

……が、何度このコマンドを実行しても一時的にファン回転数が下がるもののまた最大に戻るという挙動が発生。煩すぎてまともに作業できないので一旦電源を落として情報を探したところ、次のフォーラムでBMCのアップデートを行うことが推奨されていた。

forums.servethehome.com

結局こちらの内容がドンピシャで、BMCをアップデートしたらちゃんと上記コマンドでファン回転数を制御できるようになりました

ちなみに、BMCのアップデートファイルはこちらで配布されています。H11SSL-Iで検索したら出てきたv2.09に更新しました。

www.supermicro.com

ROCmのインストールとPyTorchセットアップ

で、ここが本題になるはずだったのだが、あまりにも簡単にインストールできてしまったので取り立てて書くことはなく、基本的に公式ページの内容通りに作業すれば良い

最終的にTransformersのExamplesで性能検証をしていきたいので、huggingfaceのAMDページにあるインストラクションに従って作業していきます。

huggingface.co

1. Confirm you have a supported AMD hardware platform

私の買ったMI50はギリギリサポート中。Deprecated - Support will be removed in a future release.だそうです。

rocm.docs.amd.com

2. Install ROCm driver, libraries and tools

次のページを参考にインストール。

rocm.docs.amd.com

今回はAMDGPU Installerという方法を試します。基本は上記のページのコマンドを利用しますが、特にグラフィック出力は使わないので--usecaserocmのみにしておきます。

sudo apt update
wget https://repo.radeon.com/amdgpu-install/6.0.2/ubuntu/jammy/amdgpu-install_6.0.60002-1_all.deb
sudo apt install ./amdgpu-install_6.0.60002-1_all.deb
sudo amdgpu-install --usecase=rocm

20GBくらいダウンロードが行われますが、特にエラーもなく正常終了。

3. Install Machine Learning Frameworks

pipからのインストールがおすすめされています。

とりあえずvenvで仮想環境を切ったうえで、下記コマンドを実行していきます。

PyTorchは専用の--index-urlを指定し、TensorFlowはtensorflow-rocmという別名パッケージを指定してインストールするだけ。

pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/rocm5.7
pip install tensorflow-rocm
pip install transformers

これも特に問題なく実行できました。

4. ユーザーのグループ追加

rocm系のコマンドの実行にはユーザーをrendervideoというグループに参加させる必要があります。

sudo usermod -aG render USER名
sudo usermod -aG video USER名

で、最後にマシンをリブートします。リブートしないとPyTorchからGPUが見えませんでした。

5. rocm-smiの内容確認

で、ここまでやれば普通にrocm-smiというnvidia-smi的なコマンドを実行できるようになります。

表示内容は次のスクショみたいな感じ。GPU使用率、VRAM使用率など確認できます。

6. PyTorchからのGPU認識確認

で、PyTorchからどのようにROCmデバイスを利用するかといえば、基本的には普通にcudaデバイスと同じ扱いで良いようです。つまり、すでにNVIDIA GPUで動いているコードであれば特に変更する必要はなく、modelとデータを全部.to("cuda")で同じデバイス上に送っておけばOK。

ということで、GPU認識の確認もいつもどおりの次のコマンドでOK。次のスクショのような感じで確認できます。

import torch
print(torch.cuda.is_available())
print(torch.cuda.device_count())
print(torch.cuda.get_device_name())

拍子抜けするほど簡単に、なんの問題もなく2枚のMI50(RadeonVII)が認識されました。

ファンスピードを固定する

まとめにも書いたが、RadeonVIIのVBIOSが焼かれているからかもしれないが、高負荷時に存在しないはずの表示上のファンスピードが極端に遅くなり、下限に達したためかけたたましいビープ音を鳴らしたことがありました。

結果、rocm-smiの --setfan でファンスピードを固定値にしたところ問題が解消。どのみちファンはGPUカードに刺さっていないのでそこそこ高め(私はとりあえず100に設定。255段階っぽい。)に設定しておけばOKだろう。

manpages.debian.org

次回予告

次回はP40とガチンコベンチマークバトルの予定!全部データは取ってあり記事を書くだけなので、なるべく早めに上げたいと考えています。

とりあえず簡潔に言うとMI50はP40に対してそんなに性能的優位は示せていない。とはいえ、VRAMいらず単にStableDiffusionを動かすだけとかならもしかしたら最もコスパの良いGPUになるかも?といった感じ。詳細は乞うご期待。

PyTorch/TensorFlowのトレーニング時にWSL2がハングする現象

結論

今回は結論のみ。

WSL2上でPyTorch/TensorFlowで画像モデルのトレーニングを行う際に、データローダーをマルチスレッドで動かすと、トレーニングがハングする現象が発生しました。

ある程度のイテレーションは学習が進むのですが、途中で突然プログレスバーが全く進まなくなるという感じの挙動です。

私のケースではWSL2がWindowsディレクトリ(/mnt/c 以下)にアクセスする際のパフォーマンス問題によって起こった現象のようでした。なんとなくキャッシュと言うかバッファみたいなのがあって、それを超えた読み書きが行われると止まる、みたいな挙動に見えます。

類似の現象は以下のIssueにも報告されており、これを参考にLinux側のディレクトリ(/home 下など)にデータをコピーしてからトレーニングを行うことで解決しました。

github.com

github.com

おそらく最近言語モデルしかトレーニングしてなかったので問題になっていなかったのだと思うのですが、WSL2である程度大きなデータを並列読込みするようなケースでは注意が必要そうです。

環境情報

wsl --version
WSL バージョン: 2.0.9.0
カーネル バージョン: 5.15.133.1-1
WSLg バージョン: 1.0.59
MSRDC バージョン: 1.2.4677
Direct3D バージョン: 1.611.1-81528511
DXCore バージョン: 10.0.25131.1002-220531-1700.rs-onecore-base2-hyp
Windows バージョン: 10.0.22621.2283

【PyTorch小ネタ】 複数モデルを組み合わせたモデルにおいて別々のPretrained Weightを読み込む方法

まえがき

時の流れは早いもので、もう2024年になってしまったようです。

最近はKaggleやら実践的な個人活動は一先ずお休みし、社会人5年目になる前に一旦基礎固めし直そうと線形代数/解析学/統計の勉強をしたり新しく出たBishop本を読んだりしており、あまりアウトプットするものがありませんでした。

bishopbook.com

物買ったネタもEPYC買ったりオールフラッシュNASを作ったり好き勝手した結果欲しい物が無くなってしまったので、本当にブログに書く内容がない状態。

とはいえたまには書いとかないとアレなので、ちょっとした小ネタで更新しておきます。

最近ちょっとあったケースで、既存の2つのモデルのEmbeddingモデルの出力をくっつけて、FC層に流して何らかの出力を得るようなモデルを作りたいという相談を受けました。

例えば、画像とキャプションテキストから記事のジャンルを分類する課題を考えたとき、画像に関してはEfficientNetで、テキストはBERTで埋め込みを取得して、その出力をconcatして分類するみたいな。

こういうとき、既存のPretrained Weightを正しく読み込むのにどうすればいいのか、PyTorchのStatedictをいじりながら見ていきます。

たまにモデルのパラメータを直接さわりたいときがあると思いますが、意外と柔軟に弄れるので経験しておくと役に立つかもしれません。この辺頑張ると下の記事みたいな応用例もあるかも。

logmi.jp

まとめ

下ではうだうだと例を以て説明を書いておりますが、モデル内の一部でウェイトを読み込みたい場合、モデルのインスタンス変数として対象のレイヤーを呼び出し、load_state_dictメソッドを利用します。

model.embedding1.load_state_dict(state_dict_a)

メソッドの公式ソースは下

pytorch.org

モデル定義

デモ用に簡単な2つのモデルを作ります。若干出力サイズだけ変えてありますがほぼ同じモデルです。

実際には片方が画像用、もう片方がテキスト用のモデルみたいなケースを想像しておいてください

import torch
import torch.nn as nn
import torch.nn.functional as F

class ModelA(nn.Module):
    def __init__(self):
        super(ModelA, self).__init__()
        self.conv1 = nn.Conv2d(3, 6, 5)
        self.pool = nn.MaxPool2d(2, 2)
        self.fc1 = nn.Linear(6 * 13 * 13, 120)
        self.fc2 = nn.Linear(120, 84)
        self.fc3 = nn.Linear(84, 10)

    def forward(self, x):
        x = self.pool(F.relu(self.conv1(x)))
        x = x.view(-1, 6 * 13 * 13)
        x = F.relu(self.fc1(x))
        x = F.relu(self.fc2(x))
        x = self.fc3(x)
        return x
    
class ModelB(nn.Module):
    def __init__(self):
        super(ModelB, self).__init__()
        self.conv1 = nn.Conv2d(3, 6, 5)
        self.pool = nn.MaxPool2d(2, 2)
        self.fc1 = nn.Linear(6 * 13 * 13, 120)
        self.fc2 = nn.Linear(120, 84)
        self.fc3 = nn.Linear(84, 20)

    def forward(self, x):
        x = self.pool(F.relu(self.conv1(x)))
        x = x.view(-1, 6 * 13 * 13)
        x = F.relu(self.fc1(x))
        x = F.relu(self.fc2(x))
        x = self.fc3(x)
        return x
    

で、この2つのモデルを結合して利用するモデルを次のように定義します。

ここで設定するインスタンス変数名のembedding1embedding2が後々大事になります。

class CombinedModel(nn.Module):
    def __init__(self):
        super(CombinedModel, self).__init__()
        self.embedding1 = ModelA()
        self.embedding2 = ModelB()

        self.activation = nn.ReLU()
        self.fc1 = nn.Linear(30, 10)
        self.fc2 = nn.Linear(10, 1)

    def forward(self, x, y):
        x = self.embedding1(x) # [bs, 10]
        y = self.embedding2(y) # [bs, 20]

        z = torch.cat((x, y), 1) # [bs, 30]

        z = self.activation(self.fc1(z))
        z = self.fc2(z)
        return z

パラメータの操作

ModelAとModelBに関して、Weightを手動で割り当てるためにstate dictを作っていきます。

名前の通り、state dictはdict型でモデルの重みやOptimizerなどの状態を保存します。

今回は重みのみを保存しますので、単にLayer名をキーとして重みをバリューに持つDictを用意します。

本来は学習によって有意義な重みを学習するところですが、今回はシンプルにmodel_aは全部0,model_bは全部1の重みを設定します。

model_a = ModelA()
model_b = ModelB()

state_dict_a = {}
for param_tensor in model_a.state_dict():
    print(param_tensor, "\t", model_a.state_dict()[param_tensor].size())
    state_dict_a[param_tensor] = torch.zeros(model_a.state_dict()[param_tensor].size())

state_dict_b = {}
for param_tensor in model_b.state_dict():
    print(param_tensor, "\t", model_b.state_dict()[param_tensor].size())
    state_dict_b[param_tensor] = torch.ones(model_b.state_dict()[param_tensor].size())

printの出力は次のようになります。

conv1.weight     torch.Size([6, 3, 5, 5])
conv1.bias   torch.Size([6])
fc1.weight   torch.Size([120, 1014])
fc1.bias     torch.Size([120])
fc2.weight   torch.Size([84, 120])
fc2.bias     torch.Size([84])
fc3.weight   torch.Size([10, 84])
fc3.bias     torch.Size([10])
conv1.weight     torch.Size([6, 3, 5, 5])
conv1.bias   torch.Size([6])
fc1.weight   torch.Size([120, 1014])
fc1.bias     torch.Size([120])
fc2.weight   torch.Size([84, 120])
fc2.bias     torch.Size([84])
fc3.weight   torch.Size([20, 84])
fc3.bias     torch.Size([20])

作成したstate dictをモデルに読み込ませるには、load_state_dictメソッドを利用します。自前で作ったDictでも、ちゃんとキーとバリューのShapeが合っていれば読み込んでくれます。

また、保存の際にはtorch.saveを利用します。

model_a.load_state_dict(state_dict_a)
model_b.load_state_dict(state_dict_b)

torch.save(model_a.state_dict(), 'model_a.pth')
torch.save(model_b.state_dict(), 'model_b.pth')

CombinedModelにそれぞれの子モデルの重みを読み込む

CombinedModelのインスタンスを作成し、先程保存した.pthファイルを改めて読み込みます。

model = CombinedModel()
state_dict_a = torch.load('model_a.pth')
state_dict_b = torch.load('model_b.pth')

で、本題である子モデルの重みを読み込む方法ですが、単にモデルのインスタンス変数を読み出し、load_state_dictメソッドを利用します。

model.embedding1.load_state_dict(state_dict_a)
model.embedding2.load_state_dict(state_dict_b)

ちゃんと重みが更新されたかどうかを見てみます。embedding1には0、embedding1には1が格納されており、特に重みをロードしていないfc1などではランダムな値が入っており、意図通りの操作ができたことが確認できます。

print(next(model.embedding1.parameters())[0][:1])
print(next(model.embedding2.parameters())[0][:1])
print(next(model.fc1.parameters())[0])
tensor([[[0., 0., 0., 0., 0.],
         [0., 0., 0., 0., 0.],
         [0., 0., 0., 0., 0.],
         [0., 0., 0., 0., 0.],
         [0., 0., 0., 0., 0.]]], device='cuda:0', grad_fn=<SliceBackward0>)
tensor([[[1., 1., 1., 1., 1.],
         [1., 1., 1., 1., 1.],
         [1., 1., 1., 1., 1.],
         [1., 1., 1., 1., 1.],
         [1., 1., 1., 1., 1.]]], device='cuda:0', grad_fn=<SliceBackward0>)
tensor([-0.0896, -0.0968, -0.1777, -0.1720, -0.1266, -0.0351,  0.0431, -0.1412,
         0.1106,  0.0788, -0.0594,  0.1563,  0.0192, -0.1271,  0.0526, -0.1182,
        -0.0506, -0.1199, -0.0888,  0.0479, -0.0823,  0.1511,  0.1532,  0.0241,
         0.0064,  0.1728,  0.0547,  0.0753, -0.0233,  0.0545], device='cuda:0',
       grad_fn=<SelectBackward0>)

公式の説明は下。nn.Moduleのパラメータ以外、Optimizerなどのstate dict読み込みなどに関しても言及があります。

pytorch.org

Transformers Trainerを使ってみて分かりにくかった仕様など

まえがき

言語モデルを自分でガッツリ使う経験が今まで無かったので、勉強がてら先週火曜日まで開催されていたKaggle - LLM Science Exam というコンペに参加してました。

www.kaggle.com

そこそこ頑張った結果、過去最高の成績(49th/2663, Top2%)を取ることができ、銀メダルを取ることができた。SolutionなどはKaggleの方に書いたのでそちらを。

www.kaggle.com

で、この記事で触れるのは、今まで機械学習モデルのトレーニングにはPyTorchの標準的なやり方(ループ内で推論してloss計算してloss.backward()で逆伝播させて……というやつ)をなるべく使ってきたのだが、最近流行り始めているっぽいHuggingFaceのTransformers Trainer系APIを全面的に利用してみたので、そのときに困ったこととかをまとめてみるというお話。

まとめ

特にHuggingFace Transformers使う上で困ったのは次の4点。

  • wandbなどのロギングサービスとの連携がいらないときは、TrainingArgumentで、report_to="none"("none"が文字列であることに注意)を指定する。
    • 最初ローカルだけでサクッと動かしたいだけなのに、このオプションがデフォルトで有効("all")になっていて少しイラッとした。wandbが使ったら便利なサービスなのはわかるけど、デフォルトは無効になっていてほしい……。
  • モデルの中身で何しているのかを見たいときには、GitHubここから対象のモデルのフォルダの、modeling_XXX.pyを確認する
    • AutoModelForSequentialClassification()で作られるモデルが実際どのようにForward passの処理をしているのか?とかがドキュメントに書いていないので、ソースを見て納得したいようなときの話
  • PEFTのLoRAでモデルのFineTuningを行ったのだが、学習させたモデルを読み込むとClassification層の重みが初期化されてしまう現象に遭遇。
    • 次のGitHub Issueみたいな感じの現象。当然、ランダムな重みのFC層を通った結果が出てくるので、推論結果はナンセンスなものになる。
    • 結局、LoRAの重みを別に読み込んで推論用モデルの重みを上書きするのが一番確実な方法だと思う。

github.com

  • remove_unused_columnsがTrueになっているTrainerにtrain_dataset, eval_datasetを渡すと、モデルのForwardに渡されないdatasetの要素が消し去られてしまい想定しないエラーにつながることがある。
    • remove_unused_columnsはデフォルトTrueである。要注意!
    • 私の場合、使わない要素を自前で省く処理を入れたDataCollatorをTrainerに渡していたのだが、これがコンフリクトしてエラーを起こしてしまった。外で普通のtorch.utils.data.DataLoaderで使う場合エラーが出なかったので、何が原因か分からず数時間浪費した。

HuggingFace系のライブラリは便利は便利なのだが、暗黙的に行われる処理の中身が分からず何かトラブったときの対応が難しいと言うのが難点。エラーメッセージも問題の根本を直接指し示すものとはならないことが多く、GitHubとかで類似のIssueを探したり、アドホックにprint仕込んでデバックしたりする局面が何回かあった。

とはいえ、PyTorchを直接書くよりはコードの量は格段に少なくなるし、PEFTによるファインチューニングやbitsandbytesによる量子化、DeepSpeedによるVRAM使用量削減のような自前でPyTorchと合わせて使うには少し手間がかかるライブラリを使いたいとき、基本的な使い方であればHuggingFaceのオプションに指定するだけで使えたりする利便性もある。

HuggingFaceは簡単そうに見えて色々わかっていないと問題発生時に困るライブラリなので、自前のデータセットでLLMトレーニングさせたい!みたいな場合まずPyTorchを少し勉強してから使うべきものという印象でした。

PEFT関連の注意点をもう少し

まとめで書いた「LoRAの重みを別に読み込んで推論用モデルの重みを上書きする」のやり方は次のような感じ。

peft_model_id = "/kaggle/input/mistral-7b-lora-seq/checkpoint-5000/checkpoint-5000"
base_model = "/kaggle/input/mistral-7b-v0-1-starter/Mistral-7B-v0.1/Mistral-7B-v0.1"
config = LoraConfig.from_pretrained(peft_model_id)
inference_model = AutoModelForSequenceClassification.from_pretrained(base_model, num_labels=5, quantization_config=bnb_config)

# ここでトレーニングさせたLoRAの重みを直接読み込む
adapters_weights = torch.load(os.path.join(peft_model_id, 'adapter_model.bin'))

# PeftModelで学習済みのLoRAのPATHを指定しても、分類層の重みが初期化されてしまう。
model = PeftModel.from_pretrained(inference_model, peft_model_id)

# PEFTモデルの分類層の重みをLoRAの重みで上書きする
model.score.original_module.weight = Parameter(adapters_weights['base_model.model.score.weight'])

また、PEFTでLoRAとかを実際どう学習させるかなどの実装例はここを見ると良い。ドキュメントにはさらっとしか書いていないのであまり役に立たない。

github.com

remove_unused_columnsの罠の例

# こんな感じのpreprocessで、['label', "input_ids", "attention_mask", "prompt"]を要素に持つデータセットを準備する
def preprocess(row, max_seq_length=2048):
    pre_context = tokenizer.bos_token + "[INST] Answer the given multiple choice Question using the Context provided. Contexts are automatically generated and may not be reliable. In such cases, ignore the context and answer! Answers must be a single letter of the alphabet, either A, B, C, D, or E. \nContext:"
    tokenized_pre_context = tokenizer(pre_context)
    len_tokens_pre_context = len(tokenized_pre_context["input_ids"])

    options = '\n Options: A: ' + row['A'] + ' B: ' + row['B'] + ' C: ' + row['C'] + ' D: ' + row['D'] + ' E: ' + row['E'] + "\n[/INST] Answer:"
    
    tokenized_options = tokenizer(options)
    len_tokens_options = len(tokenized_options["input_ids"])
    
    tokenized_prompt = tokenizer('\n Question: ' + row["prompt"])
    len_tokens_prompt = len(tokenized_prompt["input_ids"])
    
    context = row["third_context"] + "\n" + row["second_context"] + "\n" + row["first_context"]
    max_tokens_context = max_seq_length - len_tokens_prompt - len_tokens_pre_context - len_tokens_options
    tokenized_context = tokenizer(context, max_length=max_tokens_context, truncation=True)
    
    
    example_encoded = {
        "input_ids": torch.tensor(tokenized_pre_context["input_ids"] + tokenized_context["input_ids"] + tokenized_prompt["input_ids"] + tokenized_options["input_ids"]),
        "attention_mask": torch.tensor(tokenized_pre_context["attention_mask"] + tokenized_context["attention_mask"] + tokenized_prompt["attention_mask"] + tokenized_options["attention_mask"]),
        "prompt": pre_context + context  + '\n Question: ' + row["prompt"] + options
    }


    example_encoded['label'] = option_to_index[row['answer']]
    
    return example_encoded

#  _ = [feature.pop("prompt") for feature in features] という処理でforward passで利用しない"prompt"を省く処理をCollate Functionに入れているとする
@dataclass
class DataCollatorForMultipleChoice:
    tokenizer: PreTrainedTokenizerBase
    padding: Union[bool, str, PaddingStrategy] = True
    max_length: Optional[int] = 2048
    pad_to_multiple_of: Optional[int] = None
    
    def __call__(self, features):
        labels = [feature.pop("label") for feature in features]
        _ = [feature.pop("prompt") for feature in features]
        batch_size = len(features)

        flattened_features = [[{k: v for k, v in feature.items()} for feature in features]]
        flattened_features = sum(flattened_features, [])
        batch = self.tokenizer.pad(flattened_features, padding=self.padding, max_length=self.max_length,pad_to_multiple_of=self.pad_to_multiple_of,return_tensors="pt",)
        batch = {k: v.view(batch_size, -1) for k, v in batch.items()} 
        batch["labels"] = torch.tensor(labels, dtype=torch.int64)
        return batch

# 通常のtorch.utils.data.DataLoaderでこのCollate Functionを使うのは問題ない。
train_dataloader = torch.utils.data.DataLoader(tokenized_val_dataset, batch_size=2, collate_fn=collate)

# しかし、TrainerでDataCollatorForMultipleChoiceを利用するとエラーになる!暗黙的にモデルが利用する['label', "input_ids", "attention_mask"]以外の要素(今回の場合"prompt")が削除されるため。
trainer = Trainer(
    model=lora_model,
    args=training_args,
    train_dataset=tokenized_train_dataset,
    eval_dataset=tokenized_val_dataset,
    tokenizer=tokenizer,
    data_collator=DataCollatorForMultipleChoice(tokenizer=tokenizer),
    compute_metrics=compute_metrics,
)

#これを防ぐためには、TrainingArgumentsでremove_unused_columns=False を指定する。

training_args = TrainingArguments(
...
    remove_unused_columns=False,
...
)

Ryzen9 7950X3D用にAssassin IVを買ってみた

まえがき

Ryzen9 7950X3Dを買ったのがちょうど半年前になるのだが、CPUクーラーは5600X用に購入したScytheの忍者5を流用して使っていた。

忍者5は非常に静かで安い割にそこそこ冷えるのでお気に入りであったのだが、流石に7950X3Dを冷やすには不十分でCinebenchを回すときにサーマルスロットリングに引っかかっているのが気がかりだった。

今年のComputexでNoctuaの次世代NH-D15が発表されたのでこれが出たら買い換えようと思っていたのだが、発売は来年第2四半期予定とのことでちょっと先の話になりそう。

そろそろPCの掃除がてらクーラーを交換したいというところで、ちょうどこの間DeepCoolの新作CPUクーラー Assassin IVが出たので、とりあえずこっちを買って試してみることにしたというお話。

www.aiuto-jp.co.jp

まとめ

  • Assassin IVとPCケースのTorrentとの組み合わせであれば、空冷でも7950X3Dを十分冷やせる性能がある。

    • 明確に忍者5よりも温度低下が見られ、Cinebench中でも特にサーマルスロットリングに引っかかるようなこともなくなり、しっかり170W近い消費電力を処理できている。
    • ケースの遮音性が低いこともあるが、音はフル回転だとそこそこ気になる。ただ、ちゃんとファンコントロールしてあげて低速回転にすればファン音は気にならない
  • Assassin IVは相当でかいし重い。しかし、ちゃんとマザーのヒートシンクやメモリとの干渉が無いように設計されているので高さにさえ気をつければ取り付けは問題ないと思う。

    • そもそもメモリの上までヒートシンクが届かないようにシフトされているため、よほどゴテゴテしたヒートシンクでもついていない限りは干渉しないはず(3rd fanを増設する場合を除く)
  • 間違いなく空冷最高レベルの冷却性能だが、値段が現状1.5万前後と非常に高価なので、正直値段に見合っているかといえばノー

    • 安めのAIO 360mm水冷買える値段だし、空冷ならもっとコストと性能のバランスの良いものはあるだろう。それこそ元々使っていた忍者5でも普通に使えていたし、同じDeepCoolのAK500や620、最近出たScytheのFUMA3など、適当なミドルハイ空冷でも7950X3Dであれば十分性能は出ると思う。
    • よほど見た目が気に入ったとかでなければ妥協して別のを買ったほうが幸せになれると思う。
  • Cinebench R23実行時のCPU温度をCore Tempを利用して測定。ログの結果をChatGPTのCode Interpreterにグラフ化させました。

環境

  • PCケース:Fractal Design Torrent
  • 電源: CoolerMaster MWE GOLD 1250 V2
  • マザーボード: MSI X670-P WIFI
  • メモリ: Micron 4800MHz 2x32GB
  • CPU: Ryzen9 7950X3D
  • GPU: ZOTAC RTX4090 Trinity OC
  • SSD1: ADATA XPG GAMMIX S70
  • SSD2: Micron MX500 1TB
  • CPUクーラー: Scythe 忍者5 or DeepCool Assassin IV

ギャラリー

梱包は他のDeepCool製品と似た感じで、茶色のボール箱に白を基調とした外箱のスタイル。

付属品はIntel, AMD用のマウントと工具(ドライバーとレンチ)、DM9というグリス。このDM9に関してはデータシートが見つからなかったものの工業グレードの製品との触れ込み。今回は家で余っていたMX-6を使ったので未使用です。

忍者5との比較。形状自体は忍者5と似た立方体に近い形状。単体で見るとシュッとしているデザインのため僅かに小さく感じるのだが、他のCPUクーラーと並べるとやはりデカいことがわかる。

フルタワーのTorrentに組み込んだらこんな感じ。ほぼマザーのPCIeスロットより上の部分が覆い隠される程の大きさです。買うときはサイズをしっかり確かめましょう。

ちなみに、更にメモリスロット側の方にもう1基ファンを追加することも可能です。やらんけど。

測定結果

ベンチマークとしてはCineBench R23を実行。スコアは次の通り、忍者5では微妙にサーマルスロットリングが発生しておりスコアが落ち込んでいたのだが、Assassin IVではそれが解消されて若干ながら高いスコアを出すことができた。

忍者5 Assassin IV
34815 35208

また、Core TempCineBench実行中の温度のログを取得。結果はCSVで出力されるのだが……

CPUID:,0xA60F12
Processor:,AMD Ryzen 9 7950X3D 16-Core (Raphael) 
Platform:,Socket AM5 (LGA 1718)
Revision:,
Lithography:,5nm

Session start:,20:38:19 - September 04 - 2023

Time,Core 0 Temp. (°),Core 1 Temp. (°),Core 2 Temp. (°),Core 3 Temp. (°),Core 4 Temp. (°),Core 5 Temp. (°),Core 6 Temp. (°),Core 7 Temp. (°),Core 8 Temp. (°),Core 9 Temp. (°),Core 10 Temp. (°),Core 11 Temp. (°),Core 12 Temp. (°),Core 13 Temp. (°),Core 14 Temp. (°),Core 15 Temp. (°),,Core 0,Low temp. (°),High temp. (°),Core load (%),Core speed (MHz),Core 1,Low temp. (°),High temp. (°),Core load (%),Core speed (MHz),Core 2,Low temp. (°),High temp. (°),Core load (%),Core speed (MHz),Core 3,Low temp. (°),High temp. (°),Core load (%),Core speed (MHz),Core 4,Low temp. (°),High temp. (°),Core load (%),Core speed (MHz),Core 5,Low temp. (°),High temp. (°),Core load (%),Core speed (MHz),Core 6,Low temp. (°),High temp. (°),Core load (%),Core speed (MHz),Core 7,Low temp. (°),High temp. (°),Core load (%),Core speed (MHz),Core 8,Low temp. (°),High temp. (°),Core load (%),Core speed (MHz),Core 9,Low temp. (°),High temp. (°),Core load (%),Core speed (MHz),Core 10,Low temp. (°),High temp. (°),Core load (%),Core speed (MHz),Core 11,Low temp. (°),High temp. (°),Core load (%),Core speed (MHz),Core 12,Low temp. (°),High temp. (°),Core load (%),Core speed (MHz),Core 13,Low temp. (°),High temp. (°),Core load (%),Core speed (MHz),Core 14,Low temp. (°),High temp. (°),Core load (%),Core speed (MHz),Core 15,Low temp. (°),High temp. (°),Core load (%),Core speed (MHz),CPU 0 Power,
20:38:29 09/04/23,89,89,89,89,89,89,89,89,89,89,89,89,89,89,89,89,,,43,89,100,4674.87,,43,89,100,4674.87,,43,89,100,4674.87,,43,89,100,4674.87,,43,89,100,4674.87,,43,89,100,4674.87,,43,89,100,4674.87,,43,89,100,4674.87,,43,89,100,4674.87,,43,89,100,4674.87,,43,89,100,4674.87,,43,89,100,4674.87,,43,89,100,4674.87,,43,89,100,4699.87,,43,89,100,4699.87,,43,89,100,4699.87,141.8,
20:38:39 09/04/23,89,89,89,89,89,89,89,89,89,89,89,89,89,89,89,89,,,43,89,100,4699.87,,43,89,100,4674.87,,43,89,100,4674.87,,43,89,100,4674.87,,43,89,100,4674.87,,43,89,100,4624.87,,43,89,100,4624.87,,43,89,100,4624.87,,43,89,100,4624.87,,43,89,100,4599.87,,43,89,100,4599.87,,43,89,100,4624.87,,43,89,100,4624.87,,43,89,100,4624.87,,43,89,100,4624.87,,43,89,100,4624.87,141.2,

...以下10秒ごとの測定結果...

Session end:,20:49:02 - September 04 - 2023

こんな感じの前後にメタデータが入っててフォーマットめちゃくちゃ、エンコードは何故かWindows 1252、欠損値あり、やけに列多いという面倒なCSVファイルだったので自分でグラフ化する気がなくなってしまった。

ということで、ChatGPTのCode Interpreterに処理させることに。

こんなファイルを渡してもChatGPTは健気に処理してくれまして、次のようなグラフを作成してくれました。縦軸は全コア温度の平均値(℃)、横軸は時間(分)です。

これを見ていただいてわかるように、忍者5ではリミットの90度に張り付いてしまっていたのが、Assassin IVではちゃんと90度以下をキープできています。まあ1.5万円もするクーラーなのでこのくらいやってもらわなければ困るのですが、とりあえず懸念となっていた温度問題も解決できたし久しぶりにPCの掃除もできたしまあ良しとします。

ちなみにChatGPTが作ってくれたCore Tempのログパース用コードは次のような感じ。何度かエラーに遭遇しながらも、方針を示せば後は勝手に試行錯誤してくれるので非常に楽。機密じゃないデータかつある程度枯れたライブラリでできる解析に関しては、もはや自分でやる必要ないなと改めて実感しました。

with open(file_path, 'r', encoding='ISO-8859-1') as file:
    total_lines = sum(1 for line in file)

from datetime import datetime

# 日時のパース関数を更新して非標準的な行を処理
def parse_date(x):
    try:
        return datetime.strptime(x, '%H:%M:%S %m/%d/%y')
    except ValueError:
        return pd.NaT

# 最後の行を除外してデータを読み込む
data = pd.read_csv(file_path, encoding='ISO-8859-1', skiprows=8, nrows=total_lines-9, parse_dates=[0], date_parser=parse_date)

data.set_index('Time', inplace=True)

# スタート時を0として、時間を分単位で計算
data.index = (data.index - data.index[0]).total_seconds() / 60