TECH PLAY

MNTSQ

MNTSQ の技術ブログ

86

MNTSQの溝口です。 普段からMNTSQの検索周りの開発を行っています。 MNTSQを含め、情報検索を行うモダンなアプリケーションではシンプルなUIが好まれます。 一方で、複雑な検索条件などを指定したい場合、シンプルなUIでは実現が難しいという問題があります。 その場合、シンプルなUIとは別に「詳細検索ページ」を用意するか、キーワードを入れるテキストボックスで検索式をサポートさせたりします。 今回は、直近で検索式のことを考える機会があったので、その実装手順について簡単に書こうと思います。 検索式とは 簡単に言えば、AND / OR / NOT などの論理記号と、 () での評価の優先順位の指定などです。あとは、 [フィールド名]:[キーワード] など、特定のフィールドに対するオペレーション、 ” をつかった明示的なフレーズ検索などをサポートすることもあります。 検索式を実装するに当たっては、仕様が多くなると当然実装も複雑になっていくので、この記事では以下のようなシンプルな仕様を考えます。 AND / OR / NOT を演算子としてサポート NOTを最優先で評価 ANDを次点に評価 ORを最後に評価 () で括られている部分は上記の演算子よりも優先的に評価する 演算子の評価順について少し掘り下げると、例えば以下の等式が成り立つというという意味です。 A AND NOT B OR C = (A AND (NOT B)) OR C もしANDとORの評価の優先順位が逆になった場合には、以下のようになります。 A AND NOT B OR C = A AND ((NOT B) OR C) この辺りは決めの問題なので、上述の通り NOT > AND > OR の順として進めます。 ANTLRを使った実装 検索式の実装のために、ANTLRを使います。 ANTLRのセットアップ まずはANTLRのセットアップをします。今回はとりあえずUbuntu 20.04で以下のような公式サイトのセットアップをそのまま利用します。 sudo apt install openjdk-11-jdk cd /usr/local/lib sudo curl -O https://www.antlr.org/download/antlr-4.9-complete.jar export CLASSPATH=".:/usr/local/lib/antlr-4.9-complete.jar:$CLASSPATH" alias antlr4='java -Xmx500M -cp "/usr/local/lib/antlr-4.9-complete.jar:$CLASSPATH" org.antlr.v4.Tool' alias grun='java -Xmx500M -cp "/usr/local/lib/antlr-4.9-complete.jar:$CLASSPATH" org.antlr.v4.gui.TestRig' 実装 準備も終わったので、パーサーを書いていきます。 とりあえず、SimpleQueryParser.g4というファイルを作り、先頭に以下を書きます。 grammar SimpleQueryParser; パーサーを書くときには小さく実装し、都度挙動を確かめながら進めたいので、まずは AND / OR / NOT を区別するところを書きます。 他のパーサージェネレータと同じく、ANTLRでもまずは要素を列挙していきます。まずはわかりやすいところから、 AND / OR / NOT はすべて大文字のときのみ論理演算子として認識させることとします。 AND :'AND' ; OR :'OR' ; NOT :'NOT' ; 検索キーワードと論理式の区切りは改行記号と全角半角スペースが1つ以上連続しているものとします。 WHITE_SPACE :[ \t\r\n\u3000]+ ; 検索キーワードは上記の区切り文字以外の文字列から作られるので、 KEYWORD_CHARACTER を一旦以下のように設定しておきます。 KEYWORD_CHARACTER :~(' ' |'\t' |'\r' |'\n' |'\u3000') ; 以上が基本要素です。これらを使って式(意味を持つ基本要素の集合)の形を考えていきます。 まず、検索キーワードは KEYWORD_CHARACTER が1文字以上連なっているので、以下のようになります。 keyword :KEYWORD_CHARACTER+ ; 上述の演算子の優先順位の通り、今回は NOT を最優先に評価するので、まずはキーワードの前に NOT がついているか否かをNOT式( notExpression )としてひとまとめにします。 notExpression :(NOT WHITE_SPACE)? keyword ; 例えば A AND NOT B OR C の場合には、A / NOT B / C が notExpression に相当します。 次は演算子の優先順位的に notExpression に対してAND条件がどのように作用するかを考えると、一つの notExpression に対して0回以上 AND notExpression が連なるものと考えられます。 また、全てAND条件ならばその評価順は関係ないので、ひとまとめにしても問題なさそうです。そのため、AND式( andExpression )は以下のようになります。 andExpression :notExpression (WHITE_SPACE AND WHITE_SPACE notExpression)* ; 上の例だと、 andExpression は A AND NOT B / C に相当します。 同様にOR条件を以下のように実装します。なお、AND / ORが省略された場合にはORとして扱うように OR WHITE_SPACE はあってもなくても良いものとしています。 orExpression :andExpression (WHITE_SPACE (OR WHITE_SPACE)? andExpression)* ; 一旦できたものが期待通り動くか確認してみます。これもANTLRのチュートリアル通りに以下を実行します。 antlr4 SimpleQueryParser.g4 javac SimpleQueryParser*.java grun SimpleQueryParser orExpression -gui A AND NOT B OR C ^D きちんと期待通りパースされていそうです。 次に () の処理を追加します。まずは () の要素を定義して、 KEYWORD_CHARACTER もそれに合わせて更新します。 LEFT_PAREN :'(' ; RIGHT_PAREN :')' ; KEYWORD_CHARACTER :~(' ' |'\t' |'\r' |'\n' |'\u3000' |'(' |')') ; 次に処理の優先順位を考えます。 () 内のすべての処理が優先評価されるので () のあるものは NOT よりも前、つまり keyword と同等の扱いとなります。また、() の中身には AND / OR / NOT を含む論理式が入りうるので、これは orExpression と同等と言えます。 ただ、意味を持つ最小単位である keyword とは区別したいので、以下のように keyword と notExpression の間に 基本式( baseExpression ) という式を追加して、ここに括弧に括られた orExpression と keyword を定義します。 baseExpression :keyword :LEFT_PAREN WHITE_SPACE? orExpression WHITE_SPACE? RIGHT_PAREN ; notExpression :(NOT WHITE_SPACE)? baseExpression ; これで上述の仕様を満たすものが完成しました。挙動を試してみます。 antlr4 SimpleQueryParser.g4 javac SimpleQueryParser*.java grun SimpleQueryParser orExpression -gui A AND NOT (B OR C) ^D 要素のネストがちょっと深いですが、期待通りの挙動になっています。 まとめ シンプルなクエリパーサーをANTLRを使って書いてみました。実際に使われるものはより複雑なものになりますが、自作のパーサーによって簡単に検索サービスを拡張できることは多いので、検討する価値は十分にあると思います。 この記事を書いた人 溝口泰史 MNTSQ社で検索エンジニアをしています。
アバター
はじめに python の関数に与える引数として特定の値のみを許容したいときはないでしょうか? そのようなときに、動的に引数の値をチェックして範囲外のものを除外する アサーション や、 Enum を用いてとりうる値を絞ることが考えられます。 ですが、前者は動的な値検査しか行えず、後者については API の引数の型を Enum に変更する必要があり、 Enum が既存の API を オーバーロード したときの引数の型として使えるとは限らないです。 1 このような引数の値のチェックに使える手段として、python3.8から使えるようになっている Literal types があります。 Literal types は型として宣言することで、関数の引数等に対して特定の型を要求するのと同時に、特定の値を持つことも要求できます。 例 次のようなコードの例を考えてみます。 target_fruit = [ 'apple' , 'banana' ] def print_fruit (fruit: str ): assert fruit in target_fruit print (fruit) print_fruit( 'orange' ) # 動作時にアサーションエラー ここではtype hintはprint_fruit関数の引数がstrであることまではチェックしてくれますが、中身の値が apple か banana のどちらであるかまではチェックしないです。想定しない文字列に対する処理を関数に書く必要があり、動作時の アサーション のみによってfruitの値が検証されます。 Literal types を用いた型宣言に書き換えると次のようになります。 Fruit = Literal[ 'apple' , 'banana' ] def print_fruit (fruit: Fruit): print (fruit) print_fruit( 'orange' ) # type hintingの型チェックが入る この場合、pycharmが IDE の場合には次のようなwarningが表示されます。 Literal typeの型チェックはコード実行時ではなく、静的に判定されるため、今まで引数の アサーション 等を行っていた場所に適用することで、Type hintingの恩恵が得られそうです。 Literal周りのルール Literal記述時のルールです。 Literal types の宣言方法は Literal[{許容したい値のリスト}] です。 許容したい値のリストの中身が複数である時、そのLiteral type は値のユニオンと同等です 例えば Literal[v1, v2, v3] は Union[Literal[v1], Literal[v2], Literal[v3]] と同等です。 Literalに入れる値に計算式や動的な計算結果は含められません。 例えば Literal[7] という書き方はできますが、 Literal[3 + 4] という書き方はできません。 直前に some_result = 1 + 3 と計算した直後に Literal[some_result] と書くこともできません。 Literalの中に入れられる値は以下のような動的でない値です。 整数 リテラル : 11 バイト リテラル : 0x1B ユニコード 文字列 リテラル : " apple " ブール値 リテラル : True Enum の値: Color.RED None 行列型の宣言例 Literal types と ジェネリック 型と組み合わせればサイズの情報を持つ行列型を定義することが可能です from __future__ import annotations A = TypeVar( 'A' , bound= int ) B = TypeVar( 'B' , bound= int ) C = TypeVar( 'C' , bound= int ) class Matrix (Generic[A, B]): def __add__ (self, other: Matrix[A, B]) -> Matrix[A, B]: ... def __matmul__ (self, other: Matrix[B, C]) -> Matrix[A, C]: ... def transpose (self) -> Matrix[B, A]: ... foo: Matrix[Literal[ 2 ], Literal[ 3 ]] = Matrix(...) bar: Matrix[Literal[ 3 ], Literal[ 7 ]] = Matrix(...) baz = foo @ bar pycharm環境でbaz変数にマウスオーバーするとサイズが計算された型情報が表示されます。 しかし、このような整数 リテラル を用いた型サイズの計算は、静的に宣言可能な ジェネリック 型の順序入れ替え等で可能な範囲に絞られます。 例えば2つのベクトルをconcatする操作を型で表現しようとすると、Literal types 同士を足し算する必要があり、この機能では実現できません。 class Vector (Generic[N, T]): ... def concat (vec1: Vector[A, T], vec2: Vector[B, T]) -> Vector[A + B, T]: # ...snip... おわりに 型として制約をかける値同士の動的な計算はできないという制約はあるものの、新しく実装するクラスの厳密な インターフェイス を設計する時や、既存のライブラリの動的な値エラーをなくすために Literal types は有用そうです。python3.8以降を用いているプロジェクトについて導入を検討してみてはどうでしょうか。 参考リンク PEP 586 -- Literal Types | Python.org この記事を書いた人 yad ビリヤニ 食べたい python では @overload デコレータを用いて、引数の型の複数の組み合わせをサポートする関数やメソッドを書けるようになります。 typing --- 型ヒントのサポート — Python 3.9.1 ドキュメント ↩
アバター
TL;DR TransformersのNERではFast Tokenizerを使うことで、サブ トーク ン ↔ ラベルのアラインメントが実装できる。 長いテキスト入力については、無駄なpaddingを最小限にとどめて高速処理するために、入力を固定長分割するのが良い。 検出漏れが問題になるようであれば、 ストライド 付きのwindow処理を追加するのが良い。 サンプル実装: github.com 背景 この記事を目に留めていただいた方にはおそらくおなじみであろう Hugging Face の Transformers *1 。 BERT等の Transformer素子 ベース事前学習モデルを用いた転移学習が容易に実験できるライブラリである。 最新モデルのモジュールがすごいスピードで実装されることに加えて、事前学習モデルおよび依存する トーク ナイザが一緒に管理・ダウンロードできる点がご利益として特に大きい。 文書分類やNERのような 下流 タスクは、転移学習を用いることで フルスクラッチ の 教師あり学習 に比べて少ないデータで同等以上の認識性能が達成できることが多く、Transformersは NLP の転移学習ツールの デファクト になりつつある *2 。 日本語データに対してexampleに同梱されているコードをかけようとすると(=事前学習モデル部分だけ書き換えて実行すると)文書分類はかんたんに通るが、NERはそのままではうまく行かない。要因としては大きく2点ある: 分かち書き 済みデータが仮定されており、 トーク ン-ラベル対応を保持するのが大変。 サブワードとワード単位ラベルとのアラインメントが必要。 事前学習モデルの トーク ナイザと異なる 分かち書き 単位のデー タセット を使用する場合には、 トーク ン間アラインメントによる変換(retokenization)も必要。 最大長を超える長いテキストについてはうまく事前分割しないと扱えない。 これらは固有表現抽出を行う際の頻出の課題であり、retokenization以外の論点についてはLightTagのブログ記事に素晴らしい解説がある。 www.lighttag.io 当記事ではこの頻出の問題に対するtipsを解説し、それを考慮したTransformersベースの日本語固有表現抽出のサンプル実装を行った。 Tips 2点のつまづきポイントで必要になる処理を整理すると以下のようになる。 ラベルアラインメント トーク ン単位ラベルを、サブワード トーク ン単位ラベルに変換するアラインメント処理。 NERは系列入力-系列出力のタスクであり、 トーク ンとラベルが同じ長さに対応付く必要があるため。 長いテキストの分割処理 入力系列をすべて処理したいが、メモリ都合の最大系列長制限があるため必要になる処理。 系列入力-値出力のタスクである文書分類では、雑に truncate & padding をすればとりあえず使える数字が出るが、NERはそうでない。 ラベルアラインメント Transformerベースの事前学習モデルではWordPieceや SentencePiece といったサブワード化手法が用いられる(↗︎は粗い トーク ン粒度への変換、↘︎は細かい トーク ン粒度への変換を表す)。 WordPiece encode: 文(unigrams) ↗︎ words ↘︎ subwords SentencePiece encode: 文(unigrams) ↗︎ subwords 単調な変換箇所(WordPiece:words→subwords, SentencePiece:unigrams→subwords)に限定することで、以下のような単純な処理で文字→単語対応を復元することがおおよそ可能ではある。 def char_to_token ( tokens: List[ str ], text: str , token_to_id: Callable[ str , int ], subword_prefix: Optional[ str ] = None , ) -> List[ int ]: """ Draft of function equivalent to FastTokenizer.char_to_token for WordPiece - token_to_id: spm.piece_to_id for SentencePiece """ if subword_prefix is not None : tokens_raw = [w.replace(subword_prefix, "" ) for w in tokens] else : tokens_raw = tokens assert sum ( map ( len , tokens_raw)) == len (text) token_iter = iter (tokens_raw) w = next (token_iter) word_ix = 0 token_start = 0 charid_to_tokenid = [] for i in range ( len (text)): token_end = token_start + len (w) if i >= token_end: token_start = token_end word_ix += 1 w = next (token_iter) aligned_token_id = token_to_id(tokens[word_ix]) charid_to_tokenid.append(aligned_token_id) return charid_to_tokenid 実際には空白文字のハンドルなど考慮すべき細かい点が存在し、Transformersにおいては Fast Tokenizer というTokenizerを用いることでこの機能が実装されている。Fast TokenizerはTransformers付属の tokenizers モジュールに含まれており、ベースがRust実装で高速に動作する。 長いテキストの分割処理 NERのような系列入力-系列出力のタスクでは、 メモリ都合の最大系列長制限がありつつも、以下の理由で系列の truncate および padding を避けたい。 truncate: 予測対象から漏れる系列部位が生じるのでやるべきでない。 padding: Transformersは O(#token^2) の計算複雑度を持つので、速度低下を避けるために不要なpadding tokenは最小化したい。 この点はTransformersに限らず任意のDeep系列処理に言えるtipsであるが、以下に述べる点から固定長分割を行うこと、必要ならばウィンドウ処理を組み合わせることが最も妥当そうな妥協案といえそうである。 意味的に自然な分割処理 固定長分割と対照的な分割として、文分割の処理や、 アノテーション に基づいて動的に分割する処理が挙げられる。意味的に自然であるというのは、検出漏れをなくしつつ文脈を壊しにくいという、認識性能の観点での都合である。しかしいずれの方法も以下の欠点があるためなるべく採用したくない: 速度低下:不要なpaddingを生じ、 O(#token^2) の計算複雑度が膨らむ。 非一様な可変長ブロックを生じる分割なので、テキスト長が長くなればなるほどこの点は悪化する。 認識性能向上の コスパ :分割処理の適切さが担保できない点が厄介。 文分割は一般に難しい問題である。 ルールでやるには限界があり、 言語モデル やMLで解こうとすると開発コストや速度の問題が生じる。 アノテーション に基づく動的分割は ヒューリスティック で良さが容易には定まらない。 B-タグやL-タグの周辺何 トーク ンを残せば十分な文脈といえるかのチューニングが挟まる。 固定長分割処理のメリット 単純な点に加えて、 トーク ンの テンソル 化に無駄がないため高速であることが挙げられる。 ただし、分割境界に アノテーション が位置する場合、予測ができず検出漏れを生じる点については気をつけなければならない。 エンティティがそこそこ密に分布する長文テキストに対しては、これによる認識性能低下は無視したくないこともよくある。 その場合、 ストライド 付きのウィンドウ分割処理を挟むことで、境界検出漏れ問題を緩和することができる。 *3 ストライド 付きの固定長ウィンドウ分割処理では、ウィンドウ長と ストライド 長というパラメータが生じるが、以下のように速度と認識性能をバランスするように設定する必要がある。 速度:ウィンドウ長は トーク ン長なので2乗オーダーで小さいほど速く、 ストライド 長は生成されるウィンドウ数に線形比例するので大きいほうが速い。 認識性能:ウィンドウ長が大きいほど考慮できる文脈が長く(おそらく)良く、 ストライド 長が小さいほど境界検出漏れが減って良い。 例えば、まず速度影響の大きいウィンドウ長を性能が減りすぎない程度に小さくし、 アノテーション トーク ン長の分布を考慮しつつ ストライド 長をある程度小さく設定するなどの工夫が考えられる。 サンプル実装 Transformers + PyTorch-Lightning *4 + MLflow Tracking *5 *6 の構成でサンプル実装を作成した。 github.com 参考にした実装. https://github.com/LightTag/sequence-labeling-with-transformers https://github.com/huggingface/transformers/blob/7f60e93ac5c73e74b5a00d57126d156be9dbd2b8/examples/token-classification/run_pl_ner.py *7 自動ダウンロードする日本語デー タセット : 文書分類: livedoorコーパス NER: GSDデータ 文書分類との差分 用意した2つのコードのdiffをとり、デー タセット のダウンロードロード部分を除いた本質的な差分は次のようになる。 特に、Label処理および入力系列処理の2項目が前章で解説したtipsに対応する。 差分項目 文書分類 NER 該当モジュール モデル入出力 IntList→Int IntList→IntList LightningDataModule Dataset/DataLoader *8 *9 Transformersモジュールのモデルprefix SequenceClassification TokenClassification LightningModule *10 評価メトリクス Accuracy Precision/Recall/F1 LightningModule Label処理 - subwordアラインメント LightningDataModule Dataset 入力系列処理 truncate&padding (built-in) windowing&padding LightningDataModule Dataset 追加の前処理 - ラベルスキームの変換(例 BIO→BIOLU) 分かち書き 単位の変換(例 UniDic→IPADIC) LightningDataModule 以上、Transformersで日本語固有表現抽出をやる際に重要なポイントを解説し、そのサンプル実装を紹介した。 この記事はMNTSQ株式会社の業務時間内に書かれた。 MNTSQ株式会社では契約書解析を高度化する 自然言語処理 エンジニアを募集しています: hrmos.co この記事を書いた人 稲村和樹 自然言語処理 エンジニア。爬虫類が好き。 *1 : 🤗 *2 : Flair の文字ベースLSTMのような、日本語で利用できる非Transformer系の高性能な事前学習モデルも存在する:使用例は https://github.com/kzinmr/flair_ner_ja などを参照 *3 : ウィンドウごとの認識結果をマージしなければならない点がやや煩雑なため、サンプル実装では結果のハンドルまでは行っていない。 *4 : PyTorch-Lightningについては マニュアル が簡潔かつ簡明なため一読されたい。サンプル実装については Getting started, Best practices, Lightning API , Optional extensions > LightningDataModule あたりをざっと目を通した上で作成した *5 : MLflowのautologging連携については マニュアル および 機能リリース記事 等を参照。 *6 : PyTorch-Lightning側のLoggerとして MlflowLogger も用意されているが、 インラインコメント にある理由でmlflow.pytorch利用を推奨している *7 : 記事公開時点での最新版はTransformers Trainer API やDataset API を用いた実装( run_ner.py )に切り替わっていたが、長文を扱いたい場合はcollatorの追加調整が依然必要である。 *8 : LightningModule.modelへの入力(DataLoader)では テンソル 化により差分が吸収される(See. collate_fn=InputFeaturesBatch) *9 : Transformers が用意する Trainer API の data_collator引数で使われる DataCollatorForTokenClassification を確認したが、paddingしか行わなず長いテキストの対処は事前に外部で行う想定ぽいので使用しないことにした *10 : BertForSequenceClassification / BertForTokenClassification などTransformersがタスク向けのモデルも用意してくれており、それをそのまま利用している。PreTrainedModel層をfreezeしたい場合はissueを参照: https://github.com/huggingface/transformers/issues/400#issuecomment-557300356 。decodingを工夫したい(CRF層の追加, 禁止タグ遷移(O→L, O→I)の追加, ビームサーチ等)場合はoutput.logitsをそのまま処理すればよい。
アバター
Python でデータ処理をしている際、numpyにはまらないごちゃごちゃした前処理があり、ちょっと遅いんだよなぁ。。。となること、ないでしょうか。 ルーチンになっている解析であれば高速化を頑張る意味がありそうですが、新しい解析を試行錯誤している最中など、わざわざ高速化のためのコードを ガリ ガリ 書いていくのは辛いぐらいのフェーズ、ないでしょうか。 こんなとき、私はJuliaを使っています。Juliaは特別な書き方をしなくても高速になる場合が多く、並列処理も簡単にできます。 julialang.org Julia、いいらしいが名前は聞いたことがあるけど使うまでには至ってない、という方がと思います。今まで使っているコードの資産を書き直すのは嫌ですよね。 しかし、Juliaには Python の資産を活かしつつ高速にデータ処理がするための道具がそろっています。 今回の記事は Python とJuliaをいったりきたりしながらデータ解析を行うのに役立つライブラリなどを紹介していきます。 そもそもなんでJuliaを使うのか DataFrames.jl PyCall.jl PythonとJuliaでデータ、特にDataFrameをやり取りする この記事を書いた人 そもそもなんでJuliaを使うのか いろんなところに書いてあることではありますが、Juliaは Python と同様に動的型付けなので、型を明示しなくても良い (明示してもよい) Python と同様にREPL/Jupyter Notebook対応があるため EDA しやすい Python と違って JIT があるために、がんばってnumpyやnumbaで処理を書き換えなくても速い Python よりも並列処理がやりやすい という点がハッピーです。特に並列処理に関しては、 Python と異なりGIL(Global Interpreter Lock)がないため、プロセスより通信のオーバーヘッドが小さいスレッドでちゃんとCPUヘビーな並列処理ができます。 OpenMP や D言語 のようにfor文に少し手を入れるだけで並列化ができるので、頑張ってvectorizeしたり、 multiprocessingのために関数をラップ したりしなくてよいわけです。 Threads. @threads for i = 1 : 10 a[i] = Threads.threadid() end 並列処理のためにプロセスをまたがる通信を行う必要がないため、 大きなDataFrameを複数プロセスで共有するために共有メモリを作ったりしなくても よくなります。 ただ、 Python からすっと移行できるよ!とはよく言われますが、結構とっつきにくいところもあるなというのが個人的な印象です。 オブジェクト指向 ではなく、 多重ディスパッチ ( C++ などでもありますね)によるプログラミングモデル クラスはありませんが、 Cなどの構造体に相当する複合型 が存在します 関数と型の組み合わせですべてを記述していきます。引数の型に応じて自動的に使われる関数が選択されます Python でいうと、すべての関数をfrom xxx import *しているのに相当する書き方(using XXX)が使われることが多いです 変数のスコープに関して仕様が複雑 REPL上とscirpt内での挙動が変わります 配列が1-origin array[0]はエラーになります ここが地味に Python からの移行で一番面倒な気がします それでも、とりあえず1日あればとりあえず ボトルネック のコードはJuliaで書き直せるだろう、ぐらいの学習曲線だと思います。 DataFrames.jl github.com DataFrames.jlはJuliaにおけるPandasです。groupbyやaggに相当する機能など、集計に関する基本機能はすでに揃っており、categorical変数などももちろん扱えます。 Pandasに比較すると良いところとしては以下があります Pandasは各行をiterateする処理が遅い (df.iterrowsなど)のですが、JuliaのDataFrames.jlはfor文が遅くないので、row-wiseの複雑な処理がやりやすいです 後述のように シリアライズ ・デ シリアライズ が非常に速い形式が用意されています ただし、もちろんPandasにしかない機能も大量に存在します。なので、複雑な処理をやりたい方は、まずJuliaでデータフレームのサイズを小さくするような前処理を高速にごりごりやって、それから Python /Pandasを使う、などがおすすめかもしれません。わたしは巨大なデータフレームをフィルタしたりするときによく使っています。 Pandasは(DataFrame.jlも)列志向のデータ構造なので、基本的には列で処理をするのがよいとされていますが、複雑な処理を行う際には、列単位で書くと煩雑になる場合も結構ありますよね。そういった場合、JuliaのDataFrame.jlは(列志向にも関わらず比較的)高速な処理が可能です。 実際にDataFrameの各行をiterateするfor loopを比較してみましょう。 Python : # irisデータを読み込みます from sklearn.datasets import load_iris import pandas as pd iris = load_iris() df = pd.DataFrame(iris.data, columns=iris.feature_names) # 行単位でループします def iterate_iris (iris_df): result = 0 for index, row in iris_df.iterrows(): result += row[ "sepal length (cm)" ] return result # pd.DataFrame.sumメソッドを使います def sum_iris (iris_df): return iris_df[ "sepal length (cm)" ].sum() Python だと、for文で6ms、sum関数で65μsでした。 In [27]: %timeit -n 1000 iterate_iris(df) 6.05 ms ± 16.5 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each) In [28]: %timeit -n 1000 sum_iris(df) 65 µs ± 4.19 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each) Julia: # irisデータを読み込みます using RDatasets iris = dataset( "datasets" , "iris" ) 変数 iris にはDataFrames.jlのDataFrameが格納されています。Pandasっぽいですね。 julia> iris = dataset("datasets", "iris") 150×5 DataFrame Row │ SepalLength SepalWidth PetalLength PetalWidth Species │ Float64 Float64 Float64 Float64 Cat… ─────┼───────────────────────────────────────────────────────────── 1 │ 5.1 3.5 1.4 0.2 setosa 2 │ 4.9 3.0 1.4 0.2 setosa 3 │ 4.7 3.2 1.3 0.2 setosa 4 │ 4.6 3.1 1.5 0.2 setosa 5 │ 5.0 3.6 1.4 0.2 setosa 6 │ 5.4 3.9 1.7 0.4 setosa 7 │ 4.6 3.4 1.4 0.3 setosa 8 │ 5.0 3.4 1.5 0.2 setosa 9 │ 4.4 2.9 1.4 0.2 setosa 10 │ 4.9 3.1 1.5 0.1 setosa 11 │ 5.4 3.7 1.5 0.2 setosa 12 │ 4.8 3.4 1.6 0.2 setosa 13 │ 4.8 3.0 1.4 0.1 setosa # 行単位でループします function iterate_iris(iris_df) result = 0 for row in eachrow(iris_df) result += row[:SepalLength] end return result end # 多重Dispatchにより、SepalLengthのカラムの型(Array{Float64,1})に対応したsum関数が呼ばれます function sum_iris(iris_df) return sum( iris[!,:SepalLength]) end Juliaだと、for文で12μs、sum関数で74nsでした。 julia> using Benchmark # Benchmark.jlを使います julia> @benchmark iterate_iris(iris) #@ほにゃほにゃ、というのがJuliaのマクロで、Pythonのdecoratorみたいなものだと考えるのがわかりやすいかもしれません BenchmarkTools.Trial: memory estimate: 4.69 KiB allocs estimate: 300 -------------- minimum time: 10.170 μs ( 0.00 % GC) median time: 11.127 μs ( 0.00 % GC) mean time: 12.400 μs ( 0.83 % GC) maximum time: 1.041 ms ( 98.88 % GC) -------------- samples: 10000 evals/sample: 1 julia> @benchmark sum_iris(iris) BenchmarkTools.Trial: memory estimate: 16 bytes allocs estimate: 1 -------------- minimum time: 66.047 ns ( 0.00 % GC) median time: 69.725 ns ( 0.00 % GC) mean time: 73.842 ns ( 0.45 % GC) maximum time: 1.191 μs ( 94.23 % GC) -------------- samples: 10000 evals/sample: 964 Juliaのほうがそれぞれ桁違いに速いですね。Julia/ Python の速度とPandas/DataFrames.jlの速度を分離してはかってないですし、全然網羅的な検証ではないので大きな主語で何かを言うつもりはないのですが、Julia + DataFrame.jlであれば、for文でも全然許容可能な水準そうであるとは言ってもよいのではないでしょうか。 もちろん Python もnumba、Cythonなどなど頑張れば速くなるのですが、上記の例を見ればわかるように、Juliaはふつうの Python に近い書き方をしてこれぐらいの速度が出ます。これはかなり使いやすいです。 さらに、DataFrames.jlは非常にload/dumpがはやいです。 JDF.jl というライブラリはマルチスレッドでデータを保存してくれ、PandasのI/Oに慣れていると本当に信じられないスピードで保存・読み込みが行われます。 PyCall.jl github.com さらに、PyCall.jlを使えばJuliaの中で Python をシームレスに読み出すことができます!どういう感じか、実際にみてみましょう Juliaをインストールしてパスが通っている前提です ( Mac であれば brew install juliaでOKです) 使うPython環境をPYTHON環境変数に指定してください PythonがJuliaから呼べるようにビルドされている必要があります juliaを起動します。ちなみに、 julia --project=. として起動すると、そのフォルダのvirtualenvから起動できます $ julia _ _ _ _(_)_ | Documentation: https://docs.julialang.org (_) | (_) (_) | _ _ _| |_ __ _ | Type "?" for help, "]?" for Pkg help. | | | | | | |/ _` | | | | |_| | | | (_| | | Version 1.5.3 (2020-11-09) _/ |\__'_|_|_|\__'_| | Official https://julialang.org/ release |__/ | julia> julia> using Pkg usingが Python のimportです。Pkgがpipに相当する機能を持つライブラリです。 julia> Pkg.add("PyCall") Updating registry at `~/.julia/registries/General` Resolving package versions... Installed VersionParsing ─ v1.2.0 Installed Conda ────────── v1.5.0 Installed MacroTools ───── v0.5.6 Installed PyCall ───────── v1.92.1 Updating `~/projects/julia-python/Project.toml` [438e738f] + PyCall v1.92.1 Updating `~/projects/julia-python/Manifest.toml` [8f4d0f93] + Conda v1.5.0 [1914dd2f] + MacroTools v0.5.6 [438e738f] + PyCall v1.92.1 [81def892] + VersionParsing v1.2.0 Building Conda ─→ `~/.julia/packages/Conda/x5ml4/deps/build.log` Building PyCall → `~/.julia/packages/PyCall/BcTLp/deps/build.log` julia> using PyCall [ Info: Precompiling PyCall [438e738f-606a-5dbb-bf0a-cddfbfd45ab0] Pkg.addでPyCallをインストールして、using PyCallします julia> sys = pyimport( "sys" ) PyObject < module 'sys' (built- in )> julia> sys.path 4 -element Array { String , 1 }: "/Users/user/.pyenv/versions/3.8.2/lib/python38.zip" "/Users/user/.pyenv/versions/3.8.2/lib/python3.8" "/Users/user/.pyenv/versions/3.8.2/lib/python3.8/lib-dynload" "/Users/user/.pyenv/versions/julia-python/lib/python3.8/site-packages" sys.pathを読み取ることができました! julia> sys.path[ 1 ] "/Users/yotaro/.pyenv/versions/3.8.2/lib/python38.zip" Python 側でのsys.path[0]が返ってきます。Juliaの1-originと Python の0-originを自動で変換してくれます。 さて、PandasのデータフレームをPyCallを通じて触ってみましょう。先程の Python コードを from sklearn.datasets import load_iris import pandas as pd iris = load_iris() df = pd.DataFrame(iris.data, columns=iris.feature_names) Juliaではこう書き換えれば良さそうです。 using PyCall datasets = pyimport( "sklearn.datasets" ) pd = pyimport( "pandas" ) iris = datasets.load_iris() df = pd.DataFrame(iris.data, columns=iris.feature_names) が、これではエラーが出てしまいます。 julia> df = pd.DataFrame(iris.data, columns=iris.feature_names) ERROR: type Dict has no field feature_names Stacktrace: [1] getproperty(::Dict{Any,Any}, ::Symbol) at ./Base.jl:33 [2] top-level scope at REPL[5]:1 iris はJuliaのDictになっています。 julia> iris Dict{Any,Any} with 7 entries: "feature_names" => ["sepal length (cm)", "sepal width (cm)", "petal length (cm)", "petal width (cm)"] "frame" => nothing "target_names" => PyObject array(['setosa', 'versicolor', 'virginica'], dtype='<U10') "data" => [5.1 3.5 1.4 0.2; 4.9 3.0 1.4 0.2; … ; 6.2 3.4 5.4 2.3; 5.9 3.0 5.1 1.8] "filename" => "/Users/yotaro/.pyenv/versions/julia-python/lib/python3.8/site-packages/sklearn/datasets/data/iris.csv" "target" => [0, 0, 0, 0, 0, 0, 0, 0, 0, 0 … 2, 2, 2, 2, 2, 2, 2, 2, 2, 2] "DESCR" => ".. _iris_dataset:\n\nIris plants dataset\n--------------------\n\n**Data Set Characteristics:**\n\n :Number of Instances: 150 (50 in each of three classes)\n :Number of Attributes: 4 numeric, predictive … もともと Python では sklearn.utils.Bunch というクラスでした。 In [ 10 ]: type (iris) Out[ 10 ]: sklearn.utils.Bunch In [ 11 ]: iris.feature_names Out[ 11 ]: [ 'sepal length (cm)' , 'sepal width (cm)' , 'petal length (cm)' , 'petal width (cm)' ] PyCallが適宜 Python のオブジェクトをJuliaのオブジェクトに変換してくれているのです。 primitiveなobjectの場合はメモリコピーを発生させずに直接アクセス・書き換えを行っているらしく、Juliaの関数で Python の大きなデータをガンガンいじることができます。 iris がDIctになっていることを反映して先程のコードを書き換えると、PandasのDataFrameを得ることができました。PandasのDataFrameは残念ながらJuliaのDataFramesに自動変換といったことはできません。 PyObject となってしまいます。 julia> df = pd.DataFrame(iris[ "data" ], columns=iris[ "feature_names" ]) PyObject sepal length (cm) sepal width (cm) petal length (cm) petal width (cm) 0 5.1 3.5 1.4 0.2 1 4.9 3.0 1.4 0.2 2 4.7 3.2 1.3 0.2 3 4.6 3.1 1.5 0.2 4 5.0 3.6 1.4 0.2 .. ... ... ... ... 145 6.7 3.0 5.2 2.3 146 6.3 2.5 5.0 1.9 147 6.5 3.0 5.2 2.0 148 6.2 3.4 5.4 2.3 149 5.9 3.0 5.1 1.8 [ 150 rows x 4 columns] しかし、DataFrameに生えている関数は全部自由に使えます julia> df["sepal length (cm)"] PyObject 0 5.1 1 4.9 2 4.7 3 4.6 4 5.0 ... 145 6.7 146 6.3 147 6.5 148 6.2 149 5.9 Name: sepal length (cm), Length: 150, dtype: float64 julia> df.describe() PyObject sepal length (cm) sepal width (cm) petal length (cm) petal width (cm) count 150.000000 150.000000 150.000000 150.000000 mean 5.843333 3.057333 3.758000 1.199333 std 0.828066 0.435866 1.765298 0.762238 min 4.300000 2.000000 1.000000 0.100000 25% 5.100000 2.800000 1.600000 0.300000 50% 5.800000 3.000000 4.350000 1.300000 75% 6.400000 3.300000 5.100000 1.800000 max 7.900000 4.400000 6.900000 2.500000 さらに、PyCall.jlが PyObject に対する sum 関数を定義していてくれるために、以下のコードも動きます。ここではJuliaの多重ディスパッチの機能が働いて、 df["sepal length (cm)"] が PyObject であることから、PyCallが登録している sum が使われ、 Python の方で sum が実行されている(はずです)。 julia> sum(df["sepal length (cm)"]) 876.5000000000002 このように、かなり自然に Python をJuliaの中で扱うことができます。PyCallには他にも様々な機能があるので、ぜひ使ってみてください。 Python とJuliaでデータ、特にDataFrameをやり取りする PyCallがあるといっても、まぁわざわざJuliaから Python を呼んで全部Juliaでやる、というのも大変ではあります。PandasとDataFrames.jlのデータ変換は簡単ではありませんし。 だいたいは、重い前処理をJuliaでやって、あとは Python でやる、みたいな使い方からJuliaを使い始める場合が多いのではないでしょうか。 そういった場合に困るのが、どうやって Python とJuliaでデータをやり取りするか、です。 小さければPyCallでPickleを使うのが楽かもしれません。 大きめのデータについては、基本的に Python で読み書きされているデータフォーマットについてはだいたいJuliaにもライブラリが用意されています JSON.jl Msgpack.jl その他、だいたいなんでもあります しかし、DataFrameについてはきちんと考える必要があります。 DataFrameは CSV.jl で CSV に入出力することはとても簡単なのですが、 CSV だと読み込み・書き込みが遅い。ファイルサイズが大きい 浮動小数 点の精度が心配 カテゴリカル変数などの情報が残らない などなどの悲しみがあります。 Python のPandasだとFeather, Parquet, HDF5などがDataFrameの シリアライズ のフォーマットとして使われていると思いますが、執筆日ではFeather.jlがお手軽そうです。 HDF5は読み込むのは可能なのですが、DataFrames.jlには自分で変換する必要があります。 Parquetfiles.jl はParquetフォーマットを直接DataFrameに変換できるすぐれものなのですが、執筆日現在では依存ライブラリの更新で動きません。かなしい。 using ParquetFiles, DataFrame df = load("data.parquet") |> DataFrame Featherの読み書きは Feather.jl を通して、Pandasと不自由なく読み書きできます using Feather Feather.write("data.feather", df) というわけで、 Python とJuliaを併用して、データを取り回しよく扱う方法について書いてみました!ぜひJuliaを使ってみてください。 この記事を書いた人 堅山耀太郎 MNTSQ社で取締役として 機械学習 ・ 自然言語処理 に関わるもろもろをやっています。好きな食べ物は担々麺です。
アバター
MNTSQで検索エンジニアをしている溝口です。 MNTSQのテックブログの第二回目の投稿という、非常に栄誉ある役割を仰せつかって少し戸惑っています。 MNTSQでは 自然言語処理 を利用して容易に大量の契約書の検索をすることができるプロダクトを作っているのですが、データ(=契約書)の量が増えるにつれて同じ条件でも検索にヒットする結果の数は多くなっていきます。 その場合、検索結果の順序を検索条件に合致している順で並ぶように改善していくのですが、その前段階として扱っているデータ全体の傾向を掴みたくなることがあります。 今回はその一環として、データの中にどういった単語(term)がどのくらい含まれているのか?というのを調べる際の話にフォーカスします。 (目的は全く異なりますが、tag cloudのためのデータを上位100件とかでなく全件取りたいというようなものだと思ってください。) MNTSQのプロダクトではElasticsearchを利用しており、できればElasticsearchの仕組みの中で上記ができると嬉しかったのですが、簡単に調べたところではそういう機能はなさそうだったので、上記を実現するべく何を考えたかを記事にしました。 なお、今回はわかりやすいように以下のようなmappingと登録データを仮定して話を進めていきます。 # mapping curl -XPUT " localhost:9200/docs?pretty " -H ' Content-Type: application/json ' -d ' { "mappings": { "properties": { "texts": { "type": "text" }, "tags": { "type": "keyword" } } } } ' # 登録データ curl -XPUT " localhost:9200/docs/_doc/1 " -H ' Content-Type: application/json ' -d ' { "tags": [ "test", "てすと" ], "texts": "検索てきすと" } ' curl -XPUT " localhost:9200/docs/_doc/2 " -H ' Content-Type: application/json ' -d ' { "tags": ["テスト", "てすと"], "texts": "検索したいテキスト" } ' aggregationsを使う これはすぐに思いつくところで、実際かつてElasticsearchにはterms API というものがあったらしいのですが、 terms APIを削除するというIssue が見つかります。 この削除された API が残っていれば、私が今回やりたかったことをすぐに実現できたのかまでは調べていませんが、Elasticsearchのaggregation機能を使えば、確かにtermを簡単に収集できそうです。 また、例え収集するtermが多くてもcomposit aggregationsを使ってページングしていけば(長い道のりですが)欲しい情報は取れそうな気がします。 が、これには問題があります。 例えば、以下はうまく行きますが、 curl -XPOST " localhost:9200/docs/_search?pretty " -H ' Content-Type: application/json ' -d ' { "aggs": { "terms_aggs": { "terms": { "field": "tags" } } }, "size": 0 } ' 以下だとうまくいきません。 curl -XPOST localhost:9200/docs/_search -H ' Content-Type: application/json ' -d ' { "aggs": { "terms_aggs": { "terms": { "field": "texts" } } } } ' 残念ながらanalyzerを通したフィールドに対してaggregationをとるためにはそのフィールドにfielddata=trueをmappingの中で指定しなければなりません。 そして上記を設定すると多くのメモリ領域を使ってしまうことがあります。 termを集めるのはとりあえずテストデータの単語分布をみて「何か面白い傾向が取ればいいな〜」くらいのモチベーションなので、そのためにメモリを多めに予約するのは、あまり良くはない気がします。 上記から、fielddata=trueは最後の手段として考えるが、とりあえずは別のア イデア を考えることにします。 (テストデータでデータ数そこまで大きくないのでメモリそこまで食わない & 分析専用のElasticsearchやindexを用意して、必要な時に使えば良いという話なのですが、そうすると良いネタがなかったので、ご容赦ください) termvectors API を使う 次にElasticsearchのtermvectors API を使うことを考えます。 termvectorとはtermの vector なので、特定のフィールドにおけるtermの出現回数を格納したインデックスのデータで、termvectors API はその情報を取得することができます。 termvectorはmappingの設定によって出現回数のほかに、 offsets(登録した文字列の先頭からN文字目からM文字目にtermが出現しているか) positions(対象のtermは単語分割した後の何番目のtermになるのか) payloads(termに結び付けて保存できる情報) が含まれています。今回のmapping(デフォルト値)ではoffsetsとpositionsが含まれているので、それらは邪魔なのでレスポンスから除去します。あと、デフォルトではfield_statisticsという統計値を算出しますが、それも不要なので除去します。 field_statistics=false&offsets=false&positions=false 最終的なリク エス トは以下です。 curl -XGET " localhost:9200/docs/_termvectors/1?fields=texts&field_statistics=false&offsets=false&positions=false&pretty " 以下のようなレスポンスを得られます。 { " _index " : " docs ", " _type " : " _doc ", " _id " : " 1 ", " _version " : 1 , " found " : true , " took " : 16 , " term_vectors " : { " texts " : { " terms " : { " き " : { " term_freq " : 1 } , " す " : { " term_freq " : 1 } , " て " : { " term_freq " : 1 } , " と " : { " term_freq " : 1 } , " 検 " : { " term_freq " : 1 } , " 索 " : { " term_freq " : 1 } } } } } 良さそうですが、問題があります。それは、termvectors API はドキュメントのidを指定しなければならないという点です。(URL Pathの最後の1です) 欲しいのは全ドキュメント中のtermの出現頻度なので、全ドキュメントのidを指定してループさせた上で、termごとに頻度を集計をすれば良いのですが、それならそもそもcomposit aggregationでコツコツページングしていった方が集計処理も必要なく、よっぽどシンプルです。 Lukeを使う Lucene 8.1からは Lucene に Lukeというindexブラウザが同梱されています。 実は、Lukeを使えばterm一覧とその出現頻度は以下のようにメニューのExport termsから簡単に取得できます。 取得されたterm一覧とその出現頻度は以下のようなものになります。 cat terms_texts_1606716894191.out い, 1 き, 1 し, 1 す, 1 た, 1 て, 1 と, 1 テキスト, 1 検, 2 索, 2 これで目的は達成できました。Lukeは GUI が必要なのでデータを GUI が使える環境に移動する必要があって少し手間です。 データを動かさずにtermの情報を収集する方法を考えるとします。 直接indexを読みとるプログラムを書く あまりElasticsearchの仕組みや Lucene のバンドル機能に 固執 せず、シンプルに Lucene のインデックスを読んでterm情報を抜き取れば良さそうと思い始めました。幸い確認するのはテスト用のノード一台だけなので、複数ノードの結果を合わせることは考えなくて良さそうです。 なので、簡単に以下のようなプログラムを書きました。 import java.io.IOException; import java.nio.file.Paths; import org.apache.lucene.index.*; import org.apache.lucene.store.FSDirectory; public class TermCollector { public static void main(String[] args) { String indexPath = args[ 0 ]; String fieldName = args[ 1 ]; try { IndexReader reader = DirectoryReader.open(FSDirectory.open(Paths.get(indexPath))); Terms terms = MultiTerms.getTerms(reader, fieldName); if (terms != null ) { TermsEnum te = terms.iterator(); while (te.next() != null ) { System.out.println(te.term().utf8ToString() + " \t " + te.totalTermFreq()); } } } catch (IOException e) { e.printStackTrace(); } } } ( Lucene のバージョンは8.7.0です) これを実行すると、以下のようになります。 い 1 き 1 し 1 す 1 た 1 て 1 と 1 テキスト 1 検 2 索 2 雑な感じはありますが今回の用途ではこれで良さそうです。 まとめ 当初の、Elasticsearchの仕組みの中で実現するというところはかないませんでしたが、簡単な Lucene のプログラムを書いてAnalyzerを通したテキストのtermを取得してみました。fielddata=trueを指定するとElasticsearchの API の仕組みの中で、 GUI が使える環境ではLukeを使うと非常に簡単にできますが、 Lucene は扱いやすいライブラリだと思うので、これくらいのことならば簡単なプログラムを作ってしまった方が良さそうです。 この記事を書いた人 溝口泰史 MNTSQ社で検索エンジニアをしています。
アバター
はじめに みなさんはじめまして、リーガルテック ベンチャー MNTSQの取締役の堅山です。この度弊社でテックブログを開設することになり、その第一号として記事を書いています。弊社の取り組む「法務」の世界はエンジニア・リサーチャーの方々から見ると縁遠いことも多いかなと思いますが、そういった方に向けてリーガルテック企業が実際何をやっているのか発信していけたらなと思っています。特に、 大量の契約書データを扱うソフトウェアをどうやって作っているか 機械学習 / 自然言語処理 の アルゴリズム 開発 大量のモデルとパイプラインを維持・改善するためのML Ops といったことについて弊社メンバーで書いていければと思っています。 www.mntsq.co.jp 目次 今日は、 言語モデル のpretrainについて書こうと思います。 言語モデル の進歩は目まぐるしく、BERT以来様々なモデルが発表されています。また、 huggingface/transformers などのライブラリを使えば、すでに学習済みの 言語モデル の利用については非常に手軽です。一方でそのような新しめの 言語モデル をス クラッチ で学習する方法について、私の探した範囲では実はあまりウェブに情報がなく、いろいろ試行錯誤が必要でした。その結果をハマりそうなポイントなどを含めて記事にまとめてみました。 以下の内容について記しています fairseqを使って 言語モデル をス クラッチ で学習する pytorch/xlaを使って、fairseqをTPU上で動かす Preemptive Instancesを使ってコストを抑える Tips: Tensorflow Research Cloud (TFRC)に応募してみよう こころがまえ そもそも、なんでこんなにpretrainが大変かと言うと、 言語モデル を扱うライブラリはいままさに活発に開発されており、 API や実行手順が目まぐるしく変わる 計算量が多いため、分散学習をしたり、TPUのようなデ バイス を使う必要がある。すると、インフラ周りでさらに扱わないといけないソフトウェアの範囲が増える という状況がありそうです。おそらくこの記事はすぐ古くなってしまいます。お試しいただく際は、ある特定のブランチを使わないといけないとか、古いバージョンを使わないといけないとか、 環境変数 を新たに設定しないといけないなど、いろいろな ワークアラウンド が必要になる可能性があります。ドキュメントにのっていない困ったことがあったらrepositoryのissueを目を皿のようにしてみましょう。 fairseqを使って 言語モデル をス クラッチ で学習する fairseq はpytorchベースの 言語モデル を扱うためのライブラリです。 github.com このあたりのライブラリとしては、前述のtransformersが有名だと思いますが、fairseqにはなんと CLI Interfaceがついてきます。カスタマイズ性はtransformersのほうが高いのですが、fairseqの場合は前処理を間違えていきなり変なことになる、みたいな不安はなさそうです。例えば、transformersで頑張るとすると、tokenizerの処理の方法がこのバージョンでは変わっていてtutorialのままでは正しく動かないであるとか、transformersのDatasetクラスやTrainerクラスをきちんと理解しないといけないなど、柔軟な分いろいろ大変だったりします。 fairseqの使い方はシンプルです。たくさんテキストを用意しましょう。今回私の場合は、train.txtとtest.txtというファイルに纏めておきました。今回使ったデー タセット は実は前後の文に相関がないため、BERTのようなNSP (Next Sentence Prediction)タスクが入っているモデルを学習していないのですが(Robertaを使いました)、NSPを学習する場合は文のペアを用意しないといけないため、たぶん何かしないといけないと思います(未確認です)。短いテキストファイルがたくさんある場合、何も考えずにファイルを結合してしまうと、NSPの部分でだめになってしまうかもしれないので、気をつけてください。fairseqは単語分割については面倒を見てくれません。bpeをする場合はsubword_nmtなどのbpeツールを使いましょう。 github.com 今回のデー タセット ではわたしはbpeを使ってないので、その部分は省略します。bpeによる単語分割が終わったら、fairseq-pretrainをかけます。fairseq-preprocessはテキストデータをバイナリ化して保存します。今回はすでにspaceでtokenizeされているのでspaceを使いました。 fairseq-preprocess \ --trainpref train.txt --validpref test .txt \ --workers 8   --tokenizer space ここで気をつけてほしいのが、 fairseqのCLIのドキュメントにあるオプション は、すべて有効なわけではないということです。fairseq-preprocessにも--tpuとか指定できるのですが、前処理にtpuを使えるはずもなく、ドキュメントにかかれているオプションが実際にその段階で有効なのかは確認してみてください。 さて、preprocsessが終わったら、pretrainができます。TPU上での学習は、以下のようなコマンドを走らせればOKです。(わたしが実際に使ったコマンドです)TPUでない場合は--tpuオプションを外して、--distributed-world-sizeを GPU の台数にすればそのまま動くはずです。( nvidia のapexやncclなどのインストールが必要な場合があります) fairseq-train ./data-bin/ \ --tpu --task masked_lm --criterion masked_lm \ --arch roberta_base --sample-break-mode complete \ --optimizer adam --adam-betas ' (0.9,0.98) ' --adam-eps 1e-6 \ --clip-norm 0 . 0   --lr-scheduler polynomial_decay \ --lr 1e-5 --warmup-updates 3000 --dropout 0 . 1 \ --attention-dropout 0 . 1 --weight-decay 0 . 01 \ --update-freq 1 --log-format simple --log-interval 10 \ --tokenizer space --train-subset=train --save-dir ./checkpoints \ --keep-interval-updates 10   --tokens-per-sample 32 \ --max-sentences 1792 --valid-subset test   --max-epoch=25 \ --num-workers = 2 --tensorboard-logdir ./runs --keep-last-epochs=25 \ --keep-best-checkpoints = 1 --seed 0 --distributed-world-size 8 \ --max-positions 32 > /path/to/log/ `date +\%Y\%m\%d\%H\%M\%S` .log 2 >&1 今回、メインのパラメータは、 GCPのチュートリアル からとってきています。一方で、いくつかのパラメータは実際のデータに合わせる必要があります、重要なパラメータとしては以下があります。 ./data-bin/ : 第一引数はモデルの保存先を指定します。これがデフォルトのようです --lr : learning rateはデータ、バッチサイズなどで大きく適切な値が変わります。 1e-5がよく使われている らしく、わたしのケースでもうまくいきました。1e-4, 1e-6ともに学習が進まなかったです --warmup-updates : このパラメータの回数分のminibatchを処理した段階でlrが最大値になります。1epochのminibatchの数は後述のパラメータで大きく変わるので、変更し忘れないように注意して下さい --update-freq :  何回かのminibatchの勾配をまとめます。バッチサイズを擬似的に大きくしたいときに使います 以下がbatchsizeに関するパラメータです --tokens-per-sample : 1文の最大長を指定します --max-sentences : 1つのバッチに詰め込まれる文の数を指定します。 tokens-per-sample x max-sentences がだいたいTPU上で専有するメモリサイズなので、ここを最大にできるようにしていくのが重要です。紛らわしいのですが、 --max-positions はpositional embeddingの最大値を決めるものなので、 --tokens-per-sample と同じ値を設定しておけばよさそうです。 pytorch/xlaを使って、fairseqをTPU上で動かす さて、そもそもTPUを使うには、 GCP 上で VM を立てる必要があります。TPU(-v3)には8つの独立したプロセッサがあるので、fairseqも少なくとも8プロセスをデータ送信に使います。1つのTPUのプロセッサに num-workers x 8 分のプロセスでデータを送っているようです. いまのバージョンのfairseqは、 Python のmultiprocessingで全データをメモリに読み込んでいるようで、 データサイズ x num-workers x 8 ぐらいのメモリを専有していきます。なので、めちゃくちゃ VM のメモリサイズが必要です。わたしの場合は、num-workers=1で、10 CPU、256GBメモリのマシンを使いました。 SSD などもろもろで$2.5/hourくらいかかってしまいます。結構高いですね。後で説明するpreemptive instancesを使うとこれが$0.6くらいになります。 TPU上で計算するにはTPUに コンパイル したモデルを送信しないといけません。これに、pytorch/xlaを使います。 github.com pytorch/xlaはpipなどではなく、 VM のImageでインストールされているものを使います。 VM を作成するときに、OSイメージを選択する画面で Deep Learning > pytorch/xlaを選ぶ必要があります。この VM にはanacondaがインストールされており、その中の仮想環境にtorch-xlaが何バージョンかインストールされています。 conda env list をうってみると、一覧が出てきます。私の場合はtorch-xla-1.6のvenvを使いました。 conda activate torch-xla-1. 6 前述の GCP の チュートリアル のとおり、 環境変数 を設定します。 export TPU_IP_ADDRESS= XXX.XXX.XXX.XXX export XRT_TPU_CONFIG= " tpu_worker;0; $TPU_IP_ADDRESS :8470 " ご存知の通り、anacondaは Intel C++ Compilerで コンパイル されたnumpyなどがついてくるのですが、なぜかその バグを踏み抜いている場合 があり、fairseqを実行する際に 環境変数 を渡してあげないとエラーになりました。さらに、 export MKL_THREADING_LAYER=GNUTPU としましょう。 TPUを起動しないと、 IPアドレス が決まりません。 GCP の画面で新しいノードを起動しましょう。ここで注意するべきなのが、TPUのソフトウェアの指定です。使うpytorchのバージョンに対応したものを選びましょう。普通のバージョンを選ぶときどうしません。わたしの場合は pytorch-nightly でOKでした。 あとは前述のコマンドを打てば、fairseqが動くはずです。なお、試行錯誤の過程でたくさんエラーが出ると思いますが、エラーの多くはおそらくメモリ関連のものです。わたしの場合は、multiprocessingのエラーはホストマシンのメモリが、TPUのエラーはTPUのメモリが足りないエラーでした。 Preemptive Instancesを使ってコストを抑える Preemptive instanceは最大24時間しか連続で走らせることができず、途中で突然シャットダウンされる可能性がある インスタンス です。fairseqは定期的にモデルをcheckpointとして保存してくれるので(--save-dirで保存場所を指定します。保存頻度・残しておく数も、--keep-last-epochsや--keep-best-checkpointsといったオプションで指定ができます)、突然シャットダウンされても、適切なところから再開できます。再開を勝手にしてもらうために、設定をしていきましょう。 まず、 VM がシャットダウンされたら再起動されるようにします。gcloudコマンドから VM は起動できるので、適当な手元のパソコンから watch コマンドで定期的にstartコマンドを送りましょう。すでに起動している場合は、何も起きないので安心です。 watch -n 600 gcloud compute start [ your-vm-name ] VM が起動したら、fairseqを自動で起動してもらう必要があります。いろいろ方法がありますが、一番手軽なのはcronを使うことでしょう。単にcronを使うと定期的に新しいfairseqを立ち上げてしまうので、 flock コマンドで抑制します。10分おきに再起動するなら以下の感じでしょう。新しいログファイルも作成してくれます。 VM の中で crontab -e で以下の様なコマンドを設定してください。 * 10 * * * * flock -n /home/xxx/lock \ source /anaconda3/envs/torch-xla-1.6/etc/conda/activate.d/env_vars.sh; \ / anaconda3/envs/torch-xla-1.6/bin/fairseq-train ... 2>&1 > `date`-cron.log  なお、condaのvenvをcron内でactivateするには上記のようにenvファイルを読み込む必要があります。 torch-xla-1.6 は使っているcondaのvenvで適宜調整してください。 パフォーマンス 今回の実験はTPU v3–8を1台使って行いました。わたしの場合、RTX-2080ti 1台に対して、TPU v3-8 1台でだいたい5倍速ぐらいの学習時間でした。Cloud TPU profilerを使うと、いつTPUのプロセッサが空いているかなどがわかるので、もっと最適化ができるかもしれません。 学習済みモデルを読み込んでみよう 先程のコマンドで指定した ./data-bin フォルダにモデルファイルが保存されていきます。もっともvalidation lossが低いモデルが checkpoint_best.pt で保存されています。 from fairseq.models.roberta import RobertaModel model = RobertaModel.from_pretrained( './checkpoints/' , checkpoint_file= "checkpoint_best.pt" , data_name_or_path= "./data-bin/xxxx/" , ) model.eval() model.fill_mask( "今日はいい<mask>ですね" ) 最後の行はタスクに合わせて変更して頂く必要がありますが、要はmasked language modelを試すには、 <mask> という トーク ンで単語を置き換えればOKです。こんな感じの返答が返ってくるはずです。 [( '今日はいい天気ですね' , 0.6828255653381348 , '天気' ),...] Tips: Tensorflow Research Cloud (TFRC)に応募してみよう pretrainでつらいところは、コストです。TPU-v3の利用料金はなんと$8/hourです!ですが安心してください。まず、TPUにもPreemptiveオプションがあり$2.4/hourまでコストを下げれれます。TPUもgcloudコマンドで開始できるので、上記の スクリプト をいじれば使えるようになるはずです。 もう一つ、TPUのコストを節約する方法があります。「Tensorflow Research Cloud (TFRC) 」という Google が公開しているプログラムに応募することです。これに応募してプロジェクトが認められると、私が以前申し込んだときは以下の資源へのアクセスが30日間認められました。 TPU v2-8 x5 @ europe-west-4 TPU v3-8 x5 @ europe-west-4 Preemptive TPU v2-8 x100 @ europe-west-4 Google の寛大なプログラム、すごいですね…。TFRC プログラムの参加者は、TFRC を利用した研究結果を、査読を受けた論文、 オープンソース コード、ブログ投稿などの形で全世界に公表することが求められます。条件を満たせそうであれば、応募してみるのもよいのではないでしょうか。 なお、これだけTPUが使えるならなんでもできそうですが、残念ながら複数のTPUを使ってfairseqを学習することは現時点ではできないようです。おそらくtensorflowベースで頑張ると、preemptive TPU x100の真価を発揮できるのでしょうが、なかなか大変そうです。 この記事を書いた人 堅山耀太郎 MNTSQ社で取締役として 機械学習 ・ 自然言語処理 に関わるもろもろをやっています。好きな食べ物は担々麺です。
アバター