SentencePieceで作る型番トークナイザー
はじめに
今回は型番を意味のありそうな単位に分割するtokenizerを作る話をします。
型番は日本語や英語といった自然言語ではない独自の文字列のため、既存のtokenizerは使えません。そのような文字列に特化したtokenizerを作ります。
ミスミの型番には商品の情報が詰め込まれており、その型番から情報を取り出して分析しようと思います。そのためにまず、型番を意味のある単位で分割しようとしました。
型番はごりごりのドメインの話なので、前提を少し説明します。
ミスミの型番(例えば"HBLTS8-6L-SET")は、いくつかのパラメータを"-"でつなげた形になっています。直感的には"-"で分割すればいいように感じます。しかし実際は、"-"の位置が必ずしも意味のある切れ目になっていません。
例えば、先程の型番では、最初の"-"の箇所は意味として"8-6L"のようなまとまりで分割したいです。ここは「8シリーズ」と「6シリーズ」をつなげるという意味を持っていて、"-" で区切って "6L" を切り離したくありません。一方で、2つ目の"-"で"SET"を切り出すのは意味があります。
そこで、実際に使われている型番を収集し、その分布から機械的に区切りを見出したらどうなるか試してみました。
どのようなTokenizerを使えばいいか
テキストを計算機で扱いやすくするために、単語などの単位に分割する手法がtokenizerです。
分割ルールを作るしくみの観点から、空白スペースなどの区切り文字、辞書ベース、教師なし学習にざっくりと分けられます。
英文などは、単語間に挟まったスペースで区切るだけで文を単語に分けられます。ただし、"I'm" など複数単語がくっついた表現や、"New York" など1個の固有名詞になる場合もあるので完全ではありません。
今回は、先述した"-"で区切るアイデアに相当します。
辞書ベースでは語彙テーブルやルールを辞書データとして用意します。
ミスミ型番は本来ルールに基づいているので、これが最も正確なはずです。しかし、ルールが統一的でなく非常に複雑です。さらに、商品の種類が3千万点超、スペック違いを含めると800垓と言われる規模のため、改めてルールを書き出すのが非現実的でした。ちょっとした分析のためでは辞書づくりのコストに見合いません。
教師なし学習のtokenizerは、コーパスから出現しやすい文字列を見出して、語彙テーブルを自動的に作る手法です。
今回はSentencePieceという手法を採用しました。次節で説明します。
SentencePiece
SentencePieceはニューラル言語処理向けに作られたtokenizerです。
詳細は本家記事などに譲りますが、簡単に紹介します。
SentencePieceにはByte-pair encoding (BPE)とUnigram言語モデルの2種類のアルゴリズムが実装されています。これらのアルゴリズムは単語よりも細かいsubwordの単位で分割します。
BPEはコーパスに含まれる文字またはバイトを最初の基本語彙として、基本語彙を結合するマージルールを追加していくことで学習します。一方、unigramモデルは多数の語彙から始めて、尤度を最大化するように語彙を減らしていきます。
また、空白スペースをメタ文字"_"に置換して、入力テキストを1つの文字列として扱います。そのため、MeCab[1]のような言語固有のtokenizerに依らず、どのような言語でも同列に対応できます。型番のような、自然言語に属さない文字列に対しても問題なく機能します。
これまでに、GoogleではT5、ALBERTなどの言語モデルや、LLMのGeminiやGemmaで使われています。他にもMeta社のLLaMAや、Mistral社のv3 tokenizer、Rinna社のRoBERTaやGPTなどの日本語モデルでもSentencePieceが採用されています。
OpenAIのtiktokenといったBPEの高速な実装も出てきていますが、SentencePieceも現在のニューラル言語モデルを広く支えていると言えます。
以下ではPythonでの使い方を簡単に紹介します。
インストール
pip
でインストールできます。
pip install sentencepiece
学習用データの用意
SentencePiece の学習では、各行に文を配したテキストファイルを渡すことができます。
今回は以下のように各行に1個の型番を書き出しました。
※以下は弊社ECサイトから適当に採ったイメージです。実際はECサイトのログから型番を取得してデータを作成しました。
HFS5-4040-1820
EFS6-3030-755
PSFJ6-65-FC5-A0
L-SS2FP-1250-870-32
ANBM5-25
学習
SentencePiece は学習も推論もシンプルに書けます。
pypiの説明を参考にしてスクリプトを用意しました。
vocab_size
で最終的な語彙数を、model_type
でアルゴリズムを指定できます。
import sentencepiece as spm
m_type = 'unigram'
vocab_size = 1000
results = spm.SentencePieceTrainer.train(input='train_data.txt',
model_prefix=f'sp_s{vocab_size}_{m_type}',
vocab_size=vocab_size,
model_type=m_type,
)
学習済みモデルで Tokenize
学習したモデルを読み込みます。
sp = spm.SentencePieceProcessor()
sp.load('sp_s1000_unigram.model')
型番を学習済みモデルで分割してみます。
sp.encode_as_ids
や sp.encode
を使えば分割結果が id で出力されます。
ここでは、結果の可読性のため、文字列で出力させます。
sp.encode_as_pieces('HFS6-3030-350')
出力は次のようになりました。
['▁HFS', '6-3030-', '350']
分割した分を元に戻すこともできます。言語モデルで生成したtoken列を自然文の文字列にするときに活躍します。
sp.decode_pieces(['▁HFS', '6-3030-', '350'])
学習済みモデルを Huggingface transformers で使う場合
学習した tokenizer を Huggingface の言語モデルと組み合わせて使いたいことも多いかと思います。SentencePiece の学習済みモデルを transformers
で使う方法の一例を紹介します。
ライブラリを pip でインストールします。
pip install transformers
モデルの保存先のディレクトリに以下の tokenizer_config.json ファイルを作成します。
{
"do_lower_case": false,
"vocab_file":"モデル保存先のパス/sp_s1000_unigr.model",
"unk_token":"<unk>",
"bos_token":"<s>",
"eos_token":"</s>",
"pad_token":"[PAD]"
}
使うモデルによって必要な特殊トークンが違うので、Tokenizer を適宜使い分けます。
BERT 系のモデルの場合は先頭に [CLS]
、文末に [SEP]
が付与されるようにAlbertTokenizer
を使います。
import transformers
from transformers import AlbertTokenizer
tokenizer = AlbertTokenizer.from_pretrained('モデル保存先のパス')
output = tokenizer(prd_cd_sample)
print(tokenizer.convert_ids_to_tokens(output['input_ids']))
LLaMA系で使うときは上のコードのAlbertTokenizer
を LlamaTokenizer
に、T5系では T5Tokenizer
に変更します。 LlamaTokenizer
では先頭に <s>
が、T5Tokenizer
では文末に </s>
が付与されます。
結果
ここでは学習したtokenizerについて、情報の区切りとして意味がありそうかという観点で見ていきます。下流タスクへの寄与は一旦忘れることにします。
まずは定性的に見てみます。
語彙サイズ1000で学習したtokenizerを使うと、例えば、HFS6-3030-350 というアルミフレームの型番は、
'▁HFS', '6-3030-', '350'
と分割されました。
アルミフレームの型番はこちらで説明されているように、フレームタイプ、溝のサイズ、縦横幅などの条件を組み合わせた形になっています。
見比べると、型番の規則を明示的に教えていないのに情報の切れ目としてありうる分割を獲得できています。
語彙サイズによる違い
次に、語彙サイズを100と1000の2パターンで学習して、分割の度合について簡単な実験をしてみました。アルゴリズムはunigram
です。
型番は"-"で区切られて複数の要素に分かれる形になっています。この要素を単語だと疑似的に捉えると、トークン数/要素数をfertility (i.e. 単語がいくつのsubwordに分割されるか)とみなせます。結果を下表にまとめました。
分析対象型番 | fertility (語彙サイズ100) | fertility (語彙サイズ1000) |
---|---|---|
全体 | 3.5 | 2.5 |
アルミフレームのみ | 2.5 | 1.6 |
アルミフレーム以外 | 3.6 | 2.6 |
語彙サイズが小さいため、全体的にfertilityは高めで、"-"の区切りより細かく分割されています。
アルミフレームに絞ると、語彙サイズ1000のtokenizerで値1.6をとっています。
先ほどのHFS6-3030-350という例では、"-"で3個に区切れる文字列が、3個のtokenに分割されました。この例だとfertilityは1になります。
アルミフレームの他の型番だと、例えば'▁HFS', '5-2020-', '10', '8', '1'というように、'1081'という文字列が3個に分割されました。この例ではfertilityは1.7です。ここでの'1081'という値は長さを表していて、いろいろな値を取りうる部分です。
このような数値部分が分割されることでfertilityが1より大きくなり、前の方の'HFS'などの決まったパターンのある部分は1個のtokenとして捉えられていると考えられます。
語彙サイズを100に小さくすると、fertilityは大きくなり、型番がより細かく分割されるようになります。よく使われるタイプのHFS6-3030-350と、レアなタイプのCAF6-3060-350という型番での結果を下表に示します。
語彙サイズ | HFS6-3030-350 | CAF6-3060-350 |
---|---|---|
1000 | '▁HFS', '6-3030-', '350' | '▁CAF', '6-3060-', '350' |
100 | '▁HFS', '6-3030-', '3', '50' | '▁C', 'A', 'F', '6-3060-', '3', '50' |
'HFS'や'6-3030-'という学習データによく出てくる表現は語彙サイズを小さくしても変わっていません。一方で、'CAF'という部分は、先頭のタイプ部分が文字単位にまで分割されてしまいました。
どこまで細かく分割したいかは用途によって変わるところだと思います。
上のケースでは'CAF'が1個のtokenになった方が型番の意味上はよいです。
アルゴリズムによる違い
SentencePieceに実装されているunigram言語モデルとBPEの2種類のアルゴリズムで学習し、比較してみます。学習には同じデータを使いました。vocab_size
以外のパラメータはデフォルトのままにしました。
まず、学習にかかった時間は、unigramで約15分、BPEで約1分でした。今回の学習データの型番は自然文に比べて短いものが多いので、自然文コーパスでの学習時間の傾向とは違いがあるかもしれません。
下表で、先ほどと同じ型番の例で結果を比べます。
アルゴリズム | 語彙サイズ | HFS6-3030-350 | CAF6-3060-350 |
---|---|---|---|
Unigram | 1000 | '▁HFS', '6-3030-', '350' | '▁CAF', '6-3060-', '350' |
Unigram | 100 | '▁HFS', '6-3030-', '3', '50' | '▁C', 'A', 'F', '6-3060-', '3', '50' |
BPE | 1000 | '▁HFS', '6-3030-3', '50' | '▁CAF', '6-3060-', '350' |
BPE | 100 | '▁HFS', '6-3030-', '3', '50' | '▁C', 'A', 'F', '6-30', '60-', '3', '50' |
'HFS'や'CAF'についてはアルゴリズムによる違いは見られません。ただし、上の表に挙げていない型番の中には違いが出るものもありました。
'6-3030-'や'6-3060-'についてみると細かな違いが出ています。BPEの語彙数1000では'6-3030-3'と、後ろの数値の一部がくっついた形で1個のtokenとされています。また、語彙数が少ない場合の分割に違いが出ました。
このような違いをどう捉えるかは用途によりますが、Unigramの方が型番の意味に即しているように感じられます。開発者の記事でも、BPEでは貪欲法によるエラーが散見され、unigramの方が正しい分割をしていそうと言われており、矛盾していません。
おわりに
今回は教師なし学習によって、型番という自然言語ではない文字列に対するtokenizerを作りました。この実験から、型番の意味ある単位での分割をデータ分布から自動的に獲得できることが見えてきました。今後、下流タスクに合わせた調整は必要ですが、型番データを使った分析やモデリングに活用できそうです。
上ではわかりやすくうまくいった例を主に紹介しました。しかし、登場頻度の低い型番が全て文字単位で分割されるなど、まだうまくいっていない部分もあります。そのような商品群に対する分析をする際には、コーパスの調整や語彙サイズ設定など工夫の余地があります。
型番のような自然言語ではない文字列のためのtokenizerというのはかなりニッチだとは思います。何らか共通項のある問題についてご検討される方々のご参考になれば幸いです。
-
MeCabもSentencePieceも同じ方が作ったものです。 ↩︎
Discussion