A Day in the Life

ファインチューンせずに高速に学習できる RAPIDS SVR (SVC) の紹介と MARC-ja の評価

先日参加した Kaggle コンペFeedback Prize - English Language Learningで知った手法、RAPIDS SVR (SVC) が高速に学習でき、回帰や分類タスクでは有益な手法の一つと感じたので、どのようなものかを紹介する。実際にこのコンペの上位解法では、RAPIDS SVR の手法が使われていた。

また RAPIDS SVC を使って日本語評価データセットのJGLUEのクラス分類データセットの MARC-ja を評価する。評価につかった実装はGitHub 上で公開している。

なおこの記事は、Kaggle Advent Calendar 2022の13日目の記事だ。

SVR (SVC) とは?

SVR はサポートベクタ回帰(Support Vector Regression)で、SVC はサポートベクタ分類(Support Vector Classification)である。これらに使われているアルゴリズムのSVM(Support Vector Machine)は精度が高いといわれ、一時期は一世を風靡していたと聞いている。sklearn にも実装があるので実際に使った事がある方も多いだろう。

ただ、sklearn のドキュメントに記述されているように

The implementation is based on libsvm. The fit time complexity is more than quadratic with the number of samples which makes it hard to scale to datasets with more than a couple of 10000 samples.

と、1万サンプルを超えると現実的にスケールさせることは sklearn の実装(libsvmベース)では困難のようだ。

RAPIDS SVR (SVC) とは

RAPIDS SVR (SVC) とは RAPIDS という NVIDIA が GPU でデータサイエンスを推進するプロジェクトの一つ、cuMLで実装されているSVMだ。cuML は雑に解説すると、sklearn 等で実装されている汎用的な機械学習アルゴリズムを sklearn の estimator API (fit()transform() など)に合わせ CUDA 上で動くように最適化して実装したものである。ベンチマークによると、sklearn の10~50倍は速いとか。そのため、sklearn では速度的に動かすのが困難なアルゴリズムも、cuML を使うことで高速に動かすことが可能になる。なお RAPIDS プロジェクトは他にも DataFrame を高速に扱えるcuDF等、CUDA 上で高速にあれこれできるツールがいくつもあるので、興味がある方はそちらも見るとよいだろう。

では SVR を高速に動かすことができると何が嬉しいのだろうか?その一つの答えがニューラルネットワークの出力層の埋め込み表現を特徴量として使い学習することが現実的な速度で行えることだ。つまり、既存の公開済みモデルをファインチューンせずに特徴量抽出のみに利用し、それをSVRで学習できる。また、複数のモデルの特徴量を組み合わせて学習なども簡単にできる。なおファインチューンせずに利用できるが、ファインチューンしたモデルの利用も可能である。

RAPIDS SVR --RAPIDS SVR starter kitより引用

ニューラルネットワークからの特徴量抽出方法

ではニューラルネットワークからどのように特徴量を抽出すればよいのか?例として、HuggingFace Transformers の Encoder モデルについて解説する。といっても、ほとんどの Encoder モデルでは出力層の last_hidden_state の CLS を見るか Mean Pooling すればよい。また、それらの結果は normalize して利用する。

class MeanPooling(nn.Module):
    def __init__(self, eps=1e-6):
        super(MeanPooling, self).__init__()
        self.eps = eps

    def forward(
        self, outputs: torch.Tensor, attention_mask: torch.Tensor
    ) -> torch.Tensor:
        last_hidden_state = outputs[0]
        input_mask_expanded = (
            attention_mask.unsqueeze(-1).expand(last_hidden_state.size()).float()
        )
        sum_embeddings = torch.sum(last_hidden_state * input_mask_expanded, 1)
        sum_mask = input_mask_expanded.sum(1)
        sum_mask = torch.clamp(sum_mask, min=self.eps)
        mean_embeddings = sum_embeddings / sum_mask
        return mean_embeddings


class ClsPooling(nn.Module):
    # 実際は Pooling ではなくただの CLS を取り出しているだけなので、このクラス名は良くない…
    def __init__(self):
        super(ClsPooling, self).__init__()

    def forward(
        self, outputs: torch.Tensor, attention_mask: torch.Tensor
    ) -> torch.Tensor:
        last_hidden_state = outputs[0]
        return last_hidden_state[:, 0, :]


POOLING_CLASSES = {
    "mean": MeanPooling,
    "cls": ClsPooling,
}

class TransformerEmbsModel(torch.nn.Module):
    def __init__(self, model_name: str, pooling: str = "mean"):
        super().__init__()
        self.model = AutoModel.from_pretrained(model_name)
        self.pool = POOLING_CLASSES[pooling]()

    def feature(self, inputs: dict[str, torch.Tensor]) -> torch.Tensor:
        outputs = self.model(**inputs)
        sentence_embeddings = self.pool(outputs, inputs["attention_mask"])
        # Normalize the embeddings
        sentence_embeddings = F.normalize(sentence_embeddings, p=2, dim=1)
        sentence_embeddings = sentence_embeddings.squeeze(0)
        return sentence_embeddings

    def forward(self, inputs: dict[str, torch.Tensor]) -> torch.Tensor:
        embs = self.feature(inputs)
        return embs

こんな感じで特徴量を取り出すことができる。

Rapids SVC での学習

あとは SVC で学習するだけ。SVRでもほぼ同じコードで動く。

from cuml.svm import SVC
import numpy as np

DEFAULT_SVC_PARAMS = {
    "C": 3.0,  # Penalty parameter C of the error term.
    "kernel": "rbf",  # Possible options: ‘linear’, ‘poly’, ‘rbf’, ‘sigmoid’.
    "degree": 3,
    "gamma": "scale",  # auto or scale
    "coef0": 0.0,
    "tol": 0.001,  # 0.001 = 1e-3
}

def train_svc(
    X: np.ndarray,
    y: np.ndarray,
    svc_params: dict[str, object] = DEFAULT_SVC_PARAMS,
    probability: bool = True,
) -> SVC:
    svc = SVC(**svc_params)
    svc.probability = probability
    svc.fit(X, y)
    return svc

コアの部分はほぼこれだけである。

MARC-ja を使いスコアを測る

では実際に日本語評価データセットのJGLUEのクラス分類データセットのMARC-jaを評価してみる。MARC-ja は Amazon の日本語レビューをネガポジのラベル付けした二値のクラス分類データセットで、Trainラベルが187,528サンプル、Dev(valid)ラベルが5,654サンプルある。そこそこの大きさのデータである。なおTestのデータは現在はPublicでは公開されていないようだ。

JGLUEのGitHub上には、Dev の正解率(acc)が並んでいて、例えばcl-tohoku/bert-base-japanese-v2の結果は 4epochs を回して 0.958。最高はXLM RoBERTa largeの0.964とのこと。

なお学習時間も気になるので、Colab(GPUはT4)で適当に学習を回した所、bert-base-japanese-v2の1epochを学習するのに約100分(accは1epoch目で 0.9573)、手元のRTX4090で1epochに約30分ほどかかった。

特徴量抽出~SVC で学習する

上記レポジトリで、MARC-ja のデータでNNの特徴量抽出~Rapids SVCで学習する実装を作った。では早速 cl-tohoku/bert-base-japanese-v2 でファインチューンせずにSVCで学習、評価してみよう。なお、以下の実行時間は手元のRTX4090での速度である。

$ python lib/runner.py bert-base-ja-v2-cls
[create cache] tmp/embs_cache/bert-base-ja-v2-cls.pkl.gz
100%|███████████████████████████████████████████████████████████████████████| 5861/5861 [06:04<00:00, 16.09it/s]
100%|█████████████████████████████████████████████████████████████████████████| 177/177 [00:10<00:00, 16.45it/s]
exec time: 394.05 sec
shape: (187528, 768) (5654, 768)
concat embs: (187528, 768) (5654, 768)
[train svc]
svc exec time: 17.83 sec
==================================================
bert-base-ja-v2-cls
valid acc score: 0.927661832331093
==================================================
              precision    recall  f1-score   support

    positive    0.89788   0.56691   0.69500       822
    negative    0.93067   0.98903   0.95896      4832

    accuracy                        0.92766      5654
   macro avg    0.91428   0.77797   0.82698      5654
weighted avg    0.92590   0.92766   0.92059      5654

特徴量抽出に 394秒、SVCの学習に約18秒、正解率は 0.92766 となった。なお、一度特徴量を抽出すると、その特徴量はキャッシュとして再利用される実装にしてあるので、二度目はほぼSVCの学習時間だけしか実行コストがかからない。

では続いて同じモデルの特徴量抽出を CLS でなく Mean Pooling で行った結果を見てみよう。

$ python lib/runner.py bert-base-ja-v2-mean
[load cache] tmp/embs_cache/bert-base-ja-v2-mean.pkl.gz
shape: (187528, 768) (5654, 768)
concat embs: (187528, 768) (5654, 768)
[train svc]
svc exec time: 18.44 sec
==================================================
bert-base-ja-v2-mean
valid acc score: 0.9324372125928546
==================================================
              precision    recall  f1-score   support

    positive    0.91667   0.58881   0.71704       822
    negative    0.93406   0.99089   0.96164      4832

    accuracy                        0.93244      5654
   macro avg    0.92536   0.78985   0.83934      5654
weighted avg    0.93153   0.93244   0.92608      5654

こちらは過去にすでに実行してあったので、特徴量はキャッシュが使われ、SVCだけの学習ですんでいる。正解率は 0.93244 で、CLS よりも Mean Pooling のほうが良い結果となった。ではこの2つの特徴量を使って学習するとどうなるだろうか?

$ python lib/runner.py bert-base-ja-v2-cls bert-base-ja-v2-mean
[load cache] tmp/embs_cache/bert-base-ja-v2-cls.pkl.gz
shape: (187528, 768) (5654, 768)
[load cache] tmp/embs_cache/bert-base-ja-v2-mean.pkl.gz
shape: (187528, 768) (5654, 768)
concat embs: (187528, 1536) (5654, 1536)
[train svc]
svc exec time: 30.04 sec
==================================================
bert-base-ja-v2-cls + bert-base-ja-v2-mean
valid acc score: 0.9334984082065794
==================================================
              precision    recall  f1-score   support

    positive    0.90545   0.60584   0.72595       822
    negative    0.93652   0.98924   0.96216      4832

    accuracy                        0.93350      5654
   macro avg    0.92099   0.79754   0.84405      5654
weighted avg    0.93200   0.93350   0.92782      5654

特徴量はすでにキャッシュがあるので、ほぼ一瞬で読み込まれ、SVC学習時間は30秒ほど。結果は 0.93350 となった。同じNNモデルだが、CLS と Mean Pooling で分けて特徴量を出し、一緒にSVCで学習するだけで 0.001 のスコア向上である。

古典的なTF-IDFはどうだろうか。TF-IDFで作った特徴量はさすがに次元が多すぎるので、SVDで次元圧縮して1000次元にしてSVCで学習・評価してみる。

$ python lib/runner.py tfidf
[load cache] tmp/embs_cache/tfidf.pkl.gz
shape: (187528, 1000) (5654, 1000)
concat embs: (187528, 1000) (5654, 1000)
[train svc]
svc exec time: 55.78 sec
==================================================
tfidf
valid acc score: 0.8924655111425539
==================================================
              precision    recall  f1-score   support

    positive    0.81657   0.33577   0.47586       822
    negative    0.89729   0.98717   0.94009      4832

    accuracy                        0.89247      5654
   macro avg    0.85693   0.66147   0.70797      5654
weighted avg    0.88556   0.89247   0.87260      5654

正解率は 0.89247 でそんなに良くない。未知語が多い文章では正解は期待できないのでこんなものだろう。ではbertの特徴量にtfidfを組み合わせるとどうか?

$ python lib/runner.py bert-base-ja-v2-cls bert-base-ja-v2-mean tfidf
[load cache] tmp/embs_cache/bert-base-ja-v2-cls.pkl.gz
shape: (187528, 768) (5654, 768)
[load cache] tmp/embs_cache/bert-base-ja-v2-mean.pkl.gz
shape: (187528, 768) (5654, 768)
[load cache] tmp/embs_cache/tfidf.pkl.gz
shape: (187528, 1000) (5654, 1000)
concat embs: (187528, 2536) (5654, 2536)
[train svc]
svc exec time: 53.41 sec
==================================================
bert-base-ja-v2-cls + bert-base-ja-v2-mean + tfidf
valid acc score: 0.9379200565970994
==================================================
              precision    recall  f1-score   support

    positive    0.92280   0.62530   0.74547       822
    negative    0.93957   0.99110   0.96465      4832

    accuracy                        0.93792      5654
   macro avg    0.93119   0.80820   0.85506      5654
weighted avg    0.93713   0.93792   0.93278      5654

結果は 0.93792 と bert 単体よりだいぶ高くなった。特徴量の方向性が違うtfidfが組み合わさり多様性が生じスコア向上と思われる。またSVCの学習時間も、収束が良いためかtfidf単体よりも若干早くなっていて興味深い。

こんな感じで、他にもいくつか HuggingFace に公開済み日本語モデルの特徴量を組みわせてスコアを求めてみる。

$ python lib/runner.py bert-base-ja-v2-cls bert-base-ja-v2-mean rinna-ja-roberta-base-cls rinna-ja-roberta-base-mean tfidf bert-base-ja-sentiment-cls bert-base-ja-sentiment-mean
[load cache] tmp/embs_cache/bert-base-ja-v2-cls.pkl.gz
shape: (187528, 768) (5654, 768)
...中略
[load cache] tmp/embs_cache/bert-base-ja-sentiment-mean.pkl.gz
shape: (187528, 768) (5654, 768)
concat embs: (187528, 5608) (5654, 5608)
[train svc]
svc exec time: 89.47 sec
==================================================
bert-base-ja-v2-cls + bert-base-ja-v2-mean + rinna-ja-roberta-base-cls + rinna-ja-roberta-base-mean + tfidf + bert-base-ja-sentiment-cls + bert-base-ja-sentiment-mean
valid acc score: 0.9432260346657234
==================================================
              precision    recall  f1-score   support

    positive    0.93717   0.65328   0.76989       822
    negative    0.94391   0.99255   0.96762      4832

    accuracy                        0.94323      5654
   macro avg    0.94054   0.82292   0.86876      5654
weighted avg    0.94293   0.94323   0.93887      5654

187528x5608 の特徴量をSVCで学習させると90秒。結果は正解率が 0.94323 と最高になった。ちゃんとファインチューンして学習させた bert の 0.958 と比べると、スコア的にはまだ物足りない。ただ、アンサンブルに使うモデルの一つとしては検討できそうなスコアだし、まだまだ他の特徴量を追加することでスコアが向上する可能性は充分ある。

学習速度も高速で、一番時間がNNの特徴量を一度取り出してしまえば、あとは好きに特徴量を組み合わて結果を観測できる。とすると例えば Fold 数を巨大にしても現実的な時間で処理できるであろう。

実際の Kaggle コンペでの活用

先日私が参加したコンペ、Feedback Prize - English Language Learning(テキストのスコア推論)の1位~8位の解法まとめによると、1位・3位・4位解法では Rapids SVR のモデルをアンサンブルで利用したようだ。私もSVRを試していたが、アンサンブルに組み込んだ時にPublic LBのスコア向上しなかったため、最終 sub には入れなかったが、少なくとも初期に公開されていた deverta-v3-base のfinetuneモデルよりはPublic/Private LB 共に高スコアをの結果であった。実際にアンサンブルに組み込んだコンペ終了後に確認できる Private LB ではスコアの向上が確認されたので、結果を知った今ならアンサンブルに入れるべきであった。

またPetFinder.my - Pawpularity Contest画像コンペの1位解法でもSVRを利用していたのことだ。

他にも、例えばコンペ終了間際にアンサンブルに使う別モデルを採択する際に、とりあえずSVRを通してスコアの高いモデルから学習させるというのも良いと思っている。Pretrained Embeddings are all you need (sort of ...)では、特徴量をSVRにかけた結果をリストアップしているが、実際にファインチューンして学習させてもこの結果と相関あるスコアになると思う。


おわりに

本記事では、ファインチューンせずに特徴量をそのまま学習に使える RAPIDS SVR, SVC について紹介した。ファインチューンする場合、データ量にもよるが数十分~数時間学習に時間がかかることは多々ある(現実世界のデータには更に巨大なデータもたくさんあるであろう)が、特徴量抽出で数分、学習で数秒~数十秒と「RAPID」に処理が行える SVR, SVC は Kaggle においても、それの一般的な業務や研究などにも利用しやすく思える。

今までは回帰・分類課題でニューラルネットワークで学習しない際、私の場合はたいてい勾配ブースティング決定木だけ試すに留まっていたが、高速にSVMで処理が行えるRAPIDS SVR, SVC は試すべき手法の一つとして活用していけそうだ。

記事の一覧 >

関連するかもエントリー

Kaggle コンペ Feedback Prize - English Language Learning でチーム参加15位金メダル取得で、Kaggle Master へ
Kaggle のコンペティション、Feedback Prize - English Language Learningが終わり、約2650チーム中15位で金メダル取得となった。これで合計金メダル2つ、銀メダル1つを取得し、Kaggle Competitions Master の条...
Kaggle のコンペティション、Feedback Prize - English Language Learningが終わり、約2650チ...
gzip + kNN のテキスト分類で BERT 超え論文 "Low-Resource" Text Classification: A Parameter-Free Classification Method with Compressors を実装し試す
最近公開された論文 “Low-Resource” Text Classification: A Parameter-Free Classification Method with Compressors (Jiang et al., Findings 2023) は、gzip で...
最近公開された論文 “Low-Resource” Text Classification: A Parameter-Free Classif...