今さらだけど、日本語のテキスト分類を学習してみた

# きっかけ
O'reillyの「機械学習エンジニアのためのTransformers」でHuggingFaceの使い方を勉強していたのさ。
前にも書いた通り、HuggingFaceについて人前で話す機会を得たんだけど、全然理解できていなくて、再勉強中なのよね。
いわゆる２周目。

より深く理解したくて、**2章　テキスト分類**をなんとなく日本語でやってみたくてググったのさ。

いい記事↓があったので真似してみようと思ったのさ

https://data-science-learning.com/archives/1085


# 環境
M1 MacbookAir2020
python:3.10.8
scikit-learn==1.5.1
matplotlib==3.9.0
transformers==4.43.4
torch==2.4.0
sentencepiece==0.2.0
datasets==2.20.0
fugashi==1.3.2
unidic-lite==1.0.8

(transfromers, torch, sentencepieceは`pip install transformers[torch, sentencepiece]`でインストール

# 参考書籍
もうね、僕のバイブル

https://github.com/nlp-with-transformers



# ざっくりした手順
基本的には参考にしたサイトそのまんま実行です。
1. データセットの準備
1. トークナイザを用意して、テキストをトークン化したものをデータセットに追加
1. モデルのダウンロード
1. 学習条件の設定と学習
1. 評価


# 1.データセットの準備
参考にしたホームページの通り、ライブドアニュースコーパスをダウンロード
以下のリンクの中でも、`ldcc-20140209.tar.gz`を使いました。真似っこ。

https://www.rondhuit.com/download.html

tar.gzファイルは圧縮ファイルなので、解凍します。これも参考にしたhpの真似っこ。そして、ipynbと同じ階層に`text`フォルダを作って保存しておきました。
xvfって確か、extractとvarboseと・・・なんだっけ？

```
tar -xvf ldcc-20140209.tar.gz
```

まぁライブラリのインストールとファイルの読み込みぐらいやっときましょう。
```python
import os
from glob import glob
import pandas as pd
from sklearn.metrics import classification_report
```
フォルダ名がカテゴリ名になっているのでリストを作っておきます。
```python
categories = glob('./text/*')
categories = [os.path.basename(x) for x in categories if not x.endswith('.txt')]
categories
```
さらにidに変換するための辞書を作っておく。
```python
cat2id = {v:i for i, v in enumerate(categories)}
cat2id
```

さらにデータを見てみると各ファイルの3行目から文字がある。さらに読み込んでみると改行が鬱陶しいので、改行を削除するための関数を作る。
```python
def file2text(file):
    with open(file, 'r', encoding='utf8') as f:
        lines = f.readlines()

    text = ''
    
    for line in lines[2:]:
        text += line.replace('\n', '')

    return text
```

そしていよいよ各ファイルを読み込んで、cat(カテゴリ)とtextをcolumnにしたデータフレームを作っちゃいます。
僕は辞書から作るのが好きなんで、この方式で。
それと、ある意味動作確認なので200データだけ抽出します。
```python
data_dic = {
    'cat': [],
    'text': [],
}
                       
for cat in categories:
    print(cat)
    files = glob(f'./text/{cat}/*.txt')
    for i, file in enumerate(files):
        data_dic['cat'].append(cat)
        data_dic['text'].append(file2text(file))

dataset_df = pd.DataFrame(data_dic)
# dataset_df = dataset_df.sample(frac=1, random_state=0).reset_index(drop=True) # fracは抽出割合
# dataset_df = dataset_df[:200]
dataset_df = dataset_df.sample(200, random_state=0).reset_index(drop=True)
dataset_df
```
ここで使ったsample関数。滅多に使わないんですが、便利っすね！
最高！


ここで、先に作ったカテゴリをidに変換する関数を使います。lambda関数を使ってもいいけど、今回はmap関数に慣れてみよう。
```python
dataset_df['label'] = dataset_df['cat'].map(cat2id)
dataset_df = dataset_df[['text', 'label']]
dataset_df
```

いよいよ`dataset`形式への変換です。
なんと、これだけ。HuggingFaceの良さはデータセットの作りやすさかもしれません。

```python
from datasets import Dataset

dataset = Dataset.from_pandas(dataset_df)
dataset

# 出力
# Dataset({
#     features: ['text', 'label'],
#     num_rows: 200
# })
```
ここまでで、datasetの準備完了！
と思いたいところですが、モデルに入れるにはトークナイザを通さねばいけません。
学習の都度トークナイザーをなん度も通すのはロスなので先に変換しちゃいましょう。


# 2.トークナイザを用意して、テキストをトークン化したものをデータセットに追加

まずはトークナイザを用意しましょう。
```python
from transformers import AutoTokenizer

tokenizer= AutoTokenizer.from_pretrained('cl-tohoku/bert-base-japanese-v2')
```

`dataset`には強力な`map`関数を備えていて、一気にぜ〜んぶ処理できちゃいます。
そのために事前に関数を作っておいて、map関数でその関数を渡しちゃいます。

```python
def preprocess_function(examples):
    MAX_LENGTH = 512
    return tokenizer(examples["text"], max_length=MAX_LENGTH, truncation=True)

tokenized_dataset = dataset.map(preprocess_function, batched=True)
tokenized_dataset
```

でお馴染み、学習用データと検証用データに分けます。これもとっても簡単。この辺りがdatasetライブラリの強さなのかなと思います。正直、感動もんです。

```python
tokenized_dataset = tokenized_dataset.train_test_split(test_size=0.2)
tokenized_dataset

# 出力
# DatasetDict({
#     train: Dataset({
#         features: ['text', 'label', 'input_ids', 'token_type_ids', 'attention_mask'],
#         num_rows: 160
#     })
#     test: Dataset({
#         features: ['text', 'label', 'input_ids', 'token_type_ids', 'attention_mask'],
#         num_rows: 40
#     })
# })
```



# 3.モデルのダウンロード
そしていよいよモデルのダウンロード。あ、のちのために必要なclassもインポートしておきましょう。
```python
from transformers import DataCollatorWithPadding
from transformers import AutoModelForSequenceClassification, TrainingArguments, Trainer

data_collator = DataCollatorWithPadding(tokenizer=tokenizer)
model = AutoModelForSequenceClassification.from_pretrained("cl-tohoku/bert-base-japanese-v2", num_labels=len(categories))
```

先に評価指標を作っておきますね。

```python
from sklearn.metrics import accuracy_score, f1_score

def compute_metrics(pred):
    labels = pred.label_ids
    preds = pred.predictions.argmax(-1)
    f1 = f1_score(labels, preds, average='weighted')
    acc = accuracy_score(labels, preds)
    return {'accuracy':acc, 'f1':f1}
```


# 4.学習条件の設定と学習
そして、学習用条件を作って、`Trainer`クラスのインスタンスを作ります。
後で、この辺が理由でエラー出ます。
```python
training_args = TrainingArguments(
    output_dir="./results",
    num_train_epochs=2,
    per_device_train_batch_size=16,
    per_device_eval_batch_size=16,
    weight_decay=0.01,
    # evaluation_strategy='epoch',
    eval_strategy='epoch',
    logging_strategy='epoch',
    save_strategy='epoch',
    save_total_limit=1,
    learning_rate=2e-5,
    use_cpu=True, # GPUを使用する場合はFalse
)

trainer = Trainer(
    model=model,
    args=training_args,
    compute_metrics=compute_metrics,
    train_dataset=tokenized_dataset['train'],
    eval_dataset=tokenized_dataset['test'],
    tokenizer=tokenizer,
    data_collator=data_collator,
)

trainer.train() # 学習
```

こんなエラー。
```
ValueError: You are trying to save a non contiguous tensor: `bert.encoder.layer.0.attention.self.query.weight` 
which is not allowed. It either means you are trying to save tensors which are reference of each other in which
case it's recommended to save only the full tensors, and reslice at load time, or simply call `.contiguous()` on 
your tensor to pack it before saving.
```
簡単にいうと、モデルをセーブしようとしているけど、モデルの一部は初期化されていて繋がっていないよ。だから保存したければ、`.contiguous()`関数を使って、セーブ前に繋げなさいよ。
そんな感じ。

なので、`Trainer`クラスの`save_model()`関数を修正することにします。
Trainerクラスを継承して、新しく、CustomTrainerクラスを作りました。
この辺はChatGPTさんと綿密に対話させていただきました。笑
GPTさん、マジすげぇ。

```python
# Trainerクラスを拡張して保存する前にテンソルを連続化する
class CustomTrainer(Trainer):
    def save_model(self, output_dir=None, _internal_call=False):
        if output_dir is None:
            output_dir = self.args.output_dir
        
        self.model = self.model.to('cpu')  # モデルをCPUに移動
        
        # すべてのテンソルを連続化する
        for param in self.model.parameters():
            if not param.is_contiguous():
                param.data = param.data.contiguous()
        
        super().save_model(output_dir, _internal_call)
```


作り直したCustomTrainerクラスのインスタンスを作って学習開始です。

```python
trainer = CustomTrainer(
    model=model,
    args=training_args,
    compute_metrics=compute_metrics,
    train_dataset=tokenized_dataset['train'],
    eval_dataset=tokenized_dataset['test'],
    tokenizer=tokenizer,
    data_collator=data_collator,
)

trainer.train() # ここで実行
```

せっかくなので保存しておきます。保存先は`training_args`で設定した`result`フォルダです。
```python
trainer.save_state()
trainer.save_model()
```


# 5.評価
評価結果を見てニヤニヤしましょう。
```python
from sklearn.metrics import classification_report
print(classification_report(tokenized_dataset['test']['label'], pred_label, target_names=categories, zero_division=0))
```


# まとめ

今回Qiitaに書こうと思ったのでちゃんとエラー回避できたから。
参考にさせていただいた記事とはライブラリのバージョンも異なるし、モデルもちょっと変わっているのかもしれません（調べてない）
その結果生まれたエラー、それも、僕にとっては結構難解だったのですが、GPTに理由を聞き、対象のクラスのプログラムをしっかり読み、かつ、対策についてGPTと議論した結果解決に至ったということをお伝えしたかったんです。

GPTに聞く時は前提条件をし〜っかりとしつこいほどに伝えるといい回答が得られますね。いつかはGPTのような賢くて、さらにローカルPCで動くものが出てくることを期待しています。

また、テキスト分類のチューニングが意外と身近だということがわかったし、活用する場面はアイデアがいくつか浮かんできています。
手を動かしてみるってやっぱりいいですね。

**皆さんはテキスト分類、何に使いますか？**
僕は自分が作ったプログラムがなんらかの理由でエラーを時に、その時のログを入れたらどんなエラーで止まったかを出力させてみようと思ってます。
あ、ログって基本英語で書いてるから日本語じゃなくて良かったかも。w

GitHubはこちら（マナビDXクエストアルムナイの次回HuggingFaceモクモク会のネタにしようと思ってる）

https://github.com/ikedachin/study_huggingface/blob/main/mokumoku_01/text_classification_japanese.ipynb


# 最後に宣伝
イベントのお手伝いしてます。
場所は名古屋駅近く、平日で現地開催のみですが、ぜひご参加ください。

https://kinto-technologies.connpass.com/event/325725/


ではまた〜