TECH PLAY

MNTSQ

MNTSQ の技術ブログ

91

はじめに 皆様はpythonで書かれたソフトウェアのリアーキテクティング1をどのように進めていますでしょうか? 既存のソフトウェアに新規機能が追加しにくいとか、機能が修正しにくい等の問題がある場合にリアーキテクティングは有効です。 リアーキテクティングの初手としては既存のソフトウェアが抱える課題の洗い出しが行われます。その際にソフトウェア内のモジュール同士の依存関係を図で把握したい場面があります。 モジュール同士の依存関係が図示されていれば、モジュール同士の構造上の問題点を伝えやすくなり、かつモジュール同士の関係を将来的にどのように落としていくかも議論しやすくなります。 このような用途に用いるpython用の依存関係解析ツールとして、今回はpydepsを紹介します。 pydeps.readthedocs.io なお、本記事で扱うコードは下記にアップロードしてあります。 GitHub - UsrNameu1/PydepsSample: Sample module code to describe how to use pydeps package インストール方法&ミニマムな使い方 グラフを図示する機能を有するため、本体のインストールの前にGraphvizのインストールが必要です。 Linux環境上であれば各ディストリのパッケージマネージャ経由で、OSXであればbrew経由で brew install graphviz 本体はpipでインストールします。 pip3 install pydeps 使い方は pydeps [--options] [pythonファイル名|モジュールディレクトリ名] です。 例えば以下の構造をもったディレクトリpackage1があり、 └── package1 ├── __init__.py ├── moduleA.py └── moduleB.py 各モジュールの内容が次のようになっているとします。 moduleA.py class A: def foo(self): pass moduleB.py from .moduleA import A class B: def bar(self): _ = A() この時、ルートディレクトリ上で pydeps package1 を実行することで、以下の図がsvgとして出力されます。 Out オプションの説明 以上が簡単な使い方の説明でした。その他にもpydepsには循環インポートの洗い出しや他ライブラリを図示する際の細かい設定等をオプション経由で行う機能があります。 主要なオプションを表にまとめました。 オプション 内容 -T FORMAT 図の出力フォーマット. FORMAT=svg or png --show-cycles 循環インポートが発生しているパッケージのみを図示 --max-bacon INT 対象ファイル/パッケージから依存が指定したホップ以上離れたモジュールを除外する --only MODULE_PATH 図示する対象をMODULE_PATHから始まる範囲に絞れる、スペース区切りで複数指定可能 --reverse 依存の矢印を逆方向にする --cluster 後述 このなかのフラグを使って実際にグラフを出力してみます。 --show-cyclesフラグによる循環インポートの洗い出し このフラグを用いて、循環インポートがあるようなパッケージを洗い出せます。先程のpackage1にさらに次の2つのモジュールが入ったとします。 moduleC.py from .moduleD import D class C: def foo(self): _ = D() moduleD.py from .moduleC import C class D: def bar(self): _ = C() --show-cycles フラグがない場合は、先程あったmoduleA, moduleBの双方も図に出力されますが、--show-cycles フラグを用いたコマンドの実行で循環インポートを含むノードのみ出力されます。 In pydeps package1 --show-cycles Out cluster関連機能 他のライブラリやパッケージをimportしているコードについては--cluster フラグが有用です。 package2パッケージを作成し、その中にmoduleE.pyを作成します。 ├── package1 │ ├── __init__.py │ ├── moduleA.py │ ├── moduleB.py │ ├── moduleC.py │ └── moduleD.py └─── package2 ├── __init__.py └── moduleE.py moduleE.py from sklearn.feature_extraction.text import TfidfVectorizer class E: def get_vectorizer(self): return TfidfVectorizer() その上でコマンドを実行します。 In pydeps package2/moduleE.py Out この図はscikit-learnの中身の依存関係も図示していますが、--cluster フラグを使えばモジュール同士の関係をよりざっくりと描画できます In pydeps package2/moduleE.py --cluster Out さらにフォルダアイコン形式での表示の有無や複数のノードをクラスタとして扱うかどうかを制御するための調整用フラグがあります: --max-cluster-size : フォルダアイコンとして表示されるノード数のしきい値を指定 --min-cluster-size:モジュールがクラスタとして扱われるノード数のしきい値を設定 実際に使ってみます In pydeps --cluster --min-cluster-size 2 --max-cluster-size 3 package2/moduleE.py Out 最後にモジュール範囲の最大ホップ数を指定する --max-bacon と --cluster の機能を組み合わせることで全体を俯瞰した図を見てみましょう。 ├── main.py ├── package1 │ ├── __init__.py │ ├── moduleA.py │ ├── moduleB.py │ ├── moduleC.py │ └── moduleD.py └── package2 ├── __init__.py ├── moduleE.py ├── moduleF.py └── moduleH.py main.py from package2.moduleH import H if __name__ == '__main__': _ = H() moduleF.py from package1.moduleB import B from package1.moduleC import C class F: def foo(self): _ = B() _ = C() moduleH.py from .moduleF import F from .moduleE import E class H: def bar(self): _ = E() _ = F() In pydeps --cluster --max-bacon 5 --min-cluster-size 2 --max-cluster-size 4 main.py Out おわりに この記事ではpydepsを用いてpythonコードの依存関係を図示する方法を紹介しました。本記事で紹介しきれなかった機能として設定ファイルの読み込みや、依存グラフのテキスト形式での出力等もあり、掘っていくとCI等の開発プロセスにも組み込めそうです。 この記事を書いた人 yad ビリヤニ食べたい レガシーソフトウェア改善ガイド (Object Oriented Selection) 第五章 | Chris Birchall, 吉川 邦夫↩
こんにちは、MNTSQでSREとして勤務している中原といいます。 プライベートも含めて、技術記事は久しぶりな気がします。がんばります。 さて、さっそくですが、日本人にとって、あるいは、韓国の方や中国の方も含めて、コンピュータ上でそれぞれの国の言葉を扱おうとしたときに苦労するのが文字コードです。 かつては(あるいは今も)、Shift JIS、EUC-JPなど、OSや環境などによって使われる文字コードが異なり、相互の連携や、同じOSでも設定次第で大いに苦労したものでした(と聞いておりますし、個人でPCを楽しんでいたときには苦しんだりした記憶があります)。 そうこうしているうち、多くのOSで標準的な文字コードとしてUnicodeが採用されるようになりました。Windowsでは内部でUTF-16LEを採用しています。Linuxでは、UTF-8を標準とすることが多くなりました。 Unicodeに統一がはかられるにつれ、かつてと比べ、環境差異の埋めやすさも、使える文字の種類についても増え、大きく利便性があがりました。「とりあえずUTF-8」などと、乱暴でも一杯目のビールよろしく指定しておけば、多くの場合困ることはなくなりました。 一方で、一言でUnicodeを使うという文脈の中にも様々な切り口から見た方式の種類があり、それぞれの違いを意識しなければいけない場面も出てきました。たとえば、UTF-8やUTF-16LEなどといった符号化スキーマの違いなどが有名ですが、この記事では”正規化 (Form Normalization)という側面について記述していきたいと思っています。 思っていますといっても、内容としては実は4年ほど前に個人のQiitaに書いた記事の改訂版になります。 かいつまんでまとめると、 当時、私はS3に日本語ファイル名のものをアップロードした awscliからアップロードしたファイルを検索しようとしてみたがマッチしなかった 原因はUnicode正規化の方式が、ファイル名と検索クエリの間で異なるせいで、人間には同じ見た目でも、内部的にコードが異なっておりマッチしなかった なので、どういうアップロードのしかたをしたときにS3でのファイル名がどういったフォーマットになるのかというのをまとめてみた というものでした。 正規化の種類 ユニコードの正規化とはなんでしょうか。 他のサイトにわかりやすいサイトも多くありますのでここでは割愛しますが、簡単に言えば、「が」という文字を見たときに、これを「が」という一つの文字として扱うか、「か」+「゛(濁点)」として扱うか、という点の取り決めのことです。濁点、半濁点の他、異字体(「神」と「神」など)、半角全角まで含めるか(「カ」と「カ」を同等に扱うか)などの観点でも扱いが変わります。 これらの扱いの違いによってNFC、NFD、NFKC、NFKDという4つの正規化方式があります。 Unicodeの規定では、この正規化の方式が異なっていても、それぞれ同様に扱うように決められていますが、必ずしもすべてのアプリケーションの実装がそうなっているわけではなく、また、ユースケースによっては厳密にわけなければいけない場合もあるわけで、上記のように検索に引っかからないこともあるわけです。 この正規化形式は、ソフトウェアやOSの処理系、ファイルシステムを経ることにより、暗黙的に変化する場合があります。それによって、見た目上は一致している文字列が内部では異なる文字列として扱われると言った不具合が出てくることがあるわけです。 本記事で試すこと 二つの内容を実施しました。 まず、Pythonのプログラムを使ってMac、Windowsそれぞれの環境で、NFC、NFD、NFKC、NFKDそれぞれの正規化方式と、なにも指定しない場合でファイルを作ってみて、ファイル名のバイト列がどのように生成されるかを確認してみます。 作るだけの簡単なプログラムなので、以下のようなものになります。文字列は、それぞれ濁点の含まれる文字ですね。なお、chr(0xfa19)は"神"です。 import os import unicodedata FORMS = ['default', 'NFC', 'NFKC', 'NFD', 'NFKD'] STRINGS = ["1_が", "2_ガ", "3_ガ", "4_㍊", "5_神", "6_{0}".format(chr(0xfa19))] def make_files(): for form in FORMS: os.mkdir(form) for string in STRINGS: if form == "default": with open(form + "/" + string, "w") as f: f.write("") else: with open(form + "/" + unicodedata.normalize(form, string), "w") as f: f.write("") 次に、上記の方法で作ったファイルをいくつかの方法でAmazon S3にアップロードし、S3でどういった扱いになるのかを再びPythonのプログラムで確認していきます。 def list_files(): session = boto3.session.Session() s3 = session.resource("s3") bucket = s3.Bucket(BUCKET_NAME) for i in bucket.objects.all(): print("{0}: {1}".format(i.key, i.key.encode("utf-8"))) 今回はMacで4つのアップロード方法、Windowsで5つのアップロード方法を試します。Macでは、awscliでのs3 cpとs3 sync、boto3によるPythonプログラムによるアップロード、ChromeでのAWSコンソールから。Windowsではaws cliでのs3 cpとs3 sync、boto3によるPythonプログラムによるアップロード、ChromeでのAWSコンソールから、そしてIE11でのAWSコンソールからのアップロードも追加しています。 正規化方式が5種類(なにもしないを含む)、文字が6種類、アップロードの方法が9種類とファイルシステムへの書き込み結果ということで、かけ算すると270種類の結果ができあがります。 270種すべてをここで掲示するのは難しい分量的にも難しいですし、そもそもすべてを貼る理由もないので、結果から言えることをまとめていきたいと思います。 なお、結果のCSVはこちらにアップロードしています。 実施してみてわかったこと S3はファイル名に対してなにもしていない ファイル名の正規化方式について、S3はなにか手を加えているかというと、なんも手を加えないですし、意思を持たないようです。原則、ローカルで作られたファイルは、ローカルで手を加えられない限りはそのまま上がるようです。 ただし、アップロードの過程で、一部の方法では透過的に正規化がかかる場合はあるようです(4.を参照のこと)。 aws s3の中で方法によって異なっていた結果は是正された模様 5年前試したときには、aws s3 cp と aws s3 sync でアップロードした場合に結果が異なるということがありました。今回試したなかで確認した限りでは、この現象は是正されていたようです。これはよいことだと思います。 神は正規化されるといなくなる これは知っている方には当たり前な話かもしれませんし、S3にも直接関係しませんが、ユニコード正規化をかけてしまうと「神」は「神」になってしまいます。これはPythonのunicodedataモジュールに限らないものです。 正規化しなければいけない場面や、正規化方式を統一するとデータの扱いが楽になる場面は多いのですが、データソースが変化してしまう情報も出てくる点については考慮が必要です。 Macはひどい 1.のなかで「基本的にはそのままのことが多いようです」という曖昧な書き方をしたのはこの処理のせいです。Macの処理系(おそらくFoundation APIのせい)を通すと問答無用に正規化方式がNFD/NFKDになるようです(参照)。これは他の方の記事でも指摘されていることで、Macの上で無理矢理にでもNFC/NFKCで処理したい場合にはPythonなどの処理系の中で明示的に正規化方式を指定して扱う必要がありそうです。 この点については、MacよりもWindowsの方が筋がいい挙動だなと思ったのは正直なところです。 (番外)IE11はひどい これは、正規化とは直接関わらない内容なので、ちょっとずれる話なのですが、IE11はアップロードの際にフォルダでのアップロードに対応していません。S3にファイルをアップロードする際には、ウェブインターフェースでそれぞれのディレクトリを作ってから、一つ一つファイルをアップロードする必要があります。ただ、単純に面倒くさかったです。 なお、ファイル名についての挙動は自然なものでした。 まとめ 結論としては、以前書いたものと大きくは変わりません。基本的にシステムで扱うファイル名などには、極力マルチバイト文字は使わない方がいいという簡単なものです。 とはいえ、ファイル名の情報を保持しなければいけない場面も多々発生しますし、処理の順番などにも気を遣わなければなりません。 また、今回Macでの扱いには注意が必要ということ痛感しました。今はコンテナを用いた開発が盛んですので、直接Macの上で開発する、ということを避けられる状況は整っていますが、開発はMac、本番環境はLinuxなど、環境が変わる場合には挙動も変わり得ますので、そういった考慮をした上で開発・運用した方が良さそうです。 冒頭にも書きましたが、コンピュータの上で日本語を使う際には否応なく文字コードを意識しなければなりません。今の苦しみ方は過去の苦しみ方とはまた異なりますが、今なお苦しみ続けなければいけないことには変わりがありません。 将来に渡って、なんらか根本的な解決策が発明されることを望みながら締めたいと思います。 この記事を書いた人 中原大介 MNTSQ社でSREをやってます。最近は乗り鉄してることが多いです。
前回記事 に続いてHugging Faceネタです。Transformers本体ではなく、 分かち書き を行うTokenizersライブラリの紹介をします。 Hugging Faceが開発しているTransformersでは、事前学習モデルと用いた 分かち書き 処理を同梱して配布している。 機械学習 モデルの学習時と推論時の間で 分かち書き 設定が異なったり、 分かち書き 済み公開データと 分かち書き 設定が揃っていなかったりすると、モデルの挙動が正しく再現できないので、この設定が揃うように仕組みで吸収できる良いプ ラク ティスといえる。 比較的古いバージョン *1 のTransformersが用いる トーク ナイザは、ライブラリ内に同梱される Python 実装のものであった。 日本語で配布されているTransformersモデルの事例でいうと、例えば 東北大学 の乾研究室から公開されている日本語BERTモデルでは、Transformers内の トーク ナイザ( BertTokenizer , WordpieceTokenizer )を継承した MeCab のtokenizerが定義・配布されている。これにより、学習時に用いられたtokenizerが何であるか、どういった前処理を経ているかということが追跡可能なだけでなく、ユーザーが特別意識しなくても使われるようになっている。 github.com 一方でちょうど一年ほど前に、Transformersが用いる トーク ナイザはRust実装の別ライブラリ——Tokenizersとして分離された。 Transformers内ではFastTokenizerという呼称で既存の Python 実装 トーク ナイザと区別され、v4.0.0以降ではデフォルトで使用されるようになっている: github.com 当エントリでは、なぜ新しいTokenizersを使ってみたかったのかという動機の説明と、日本語で使えるようにするための解説を行う。 筆者が自分で事前学習モデルを学習する際に、Hugging Face Tokenizersに準拠した日本語の 分かち書き を行おうとしてみて、いくつかの点で躓いたのでその点が解説の中心になる。 掲載しているコードは、 tokenizers v0.10.0 にて動作確認を行った。 *2 まえおき: 日本語のBERT トーク ナイザ事情 Tokenizersを導入することで嬉しい点がいくつかある: 日本語には余計なBERTの前処理を引数経由で簡単に外せる サブワード分割の学習が1ライブラリ内で完結し、各種のサブワード分割手法が容易に使える Rust実装で トーク ナイズ部が速くなる 出力結果のデータ型に便利な処理が備わっている 古いtokenizerと異なり、文字-単語間のアラインメントを保持した トーク ナイズを行ってくれる *3 文字列-idの相互変換をライブラリが管理してくれる truncate, padding等の深層学習 NLP で必要な処理を便利に行ってくれる 当節では最初の2点について解説する。 日本語には余計なBERTの前処理 BERT派生のモデルやライブラリにおいては、 Google のBERTの元実装のtokenizerが、前処理の細かい設定に至るまでコピーされていることが多く、Hugging Face Tokenizersもこの例外ではない。実験再現性を優先するための状況だが、日本語の 分かち書き を行う上ではデフォルト設定だと困った挙動をしてしまうことが知られている。 *4 日本語のオプションがおざなりになっている背景としては、 Google 版BERTの日本語モデル配布がもともと多 言語モデル として公開されたのが最初だったため、特定言語間で有効な前処理の設定が優先されていたのであろうと推測される。 具体的には以下の2点である: ひらがな・カタカナの濁点が除去されてしまう( ウムラウト など Unicode ベースのアクセント記号除去として) 漢字が必ず一文字に分割されてしまう(多 言語モデル でCJK文字のうち中国語が重要視された結果のデフォルト設定) 文書分類のように トーク ナイズ結果がどう処理されようが、良い最終出力が得られれば問題ないタスクにおいてはこの点は無視できるかもしれないが、前処理を適正化することで日本語のタスク性能が上がることもある。 また、固有表現抽出や抽出型質問応答タスクのような、入力テキストの一部を出力するようなタスクに取り組む際にこの点が気になることもある。 この点を修正するためには、BERT元実装や古いバージョンのTransformers同梱のtokenizerでは、モジュールの トーク ナイザ部分を直に書き直す必要があった。 しかし、新しいTransformers同梱のtokenizer実装では、この前処理が以下のような引数で詳細に制御できるように改善されていて、ライブラリ利用者としてはこちらを使用したい。 漢字を一文字分割しない: tokenize_chinese_chars=False 濁点を除去させない: strip_accents=False 古いバージョンでアクセント除去を無効化するには、 do_lower_case=False オプションでまるっとしか制御できなかったが、新しい版ではlower処理とアクセント除去処理の制御が分離されている。 https://huggingface.co/transformers/model_doc/bert.html#transformers.BertTokenizer 他にも細かい点だが、デフォルトで有効な unicode 正規化も場合によっては行ってほしくないことがある。 最近筆者が遭遇した事例としては、⑰のような1文字が '17' の2文字に分割されてしまい、文字列長が処理前後で変化してしまう罠にハマったりした。 後述するようにTokenizersライブラリでは前処理のパイプラインをユーザーが宣言的に定義できるので、この点も助かる。 サブワード分割の訓練が任意の別ライブラリに依存 BERT派生のモデルでは従来の単語 分かち書き だけでなく、さらに細かい 分かち書き 単位であるサブワード分割という処理が適用される。 モデル的にはこの処理は必須というわけではないものの、報告されているタスク性能のほとんどがサブワード分割を入れた上での性能値なので、特別な理由がなければ入れておきたい。 サブワード分割を行うためには、特定 コーパス に依存したサブワード分割パターンを学習・記憶するプロセスが必要になる。 vocabularyファイルがサブワード分割の学習の結果生成され *5 、サブワード分割を行う際に必要になる。 事前学習モデルに入力される トーク ンはこのvocabularyファイルに依存して決まるため、自分で事前学習を行う場合には、用いる コーパス 上でのサブワード分割を学習した上で使用するのが推奨される。 Google のBERT元実装や従来のTransformers同梱のtokenizerでは、サブワード分割の学習結果は外部から与えられるものとされており、サブワード分割手法に応じた外部ライブラリの選択はユーザーに委ねられていた。そのため、事前学習の訓練 スクリプト の他に、サブワード学習のための スクリプト や外部ライブラリを追加する必要があった。 また、BERT論文で用いているとされるWordPieceの元実装にアクセスしづらかった状況から、日本語では実装にアクセスしやすいBPEやsentencepieceといったサブワード分割手法が選ばれてきた印象がある: BPEの代表的なライブラリ: https://github.com/rsennrich/subword-nmt SentencePiece: https://github.com/google/sentencepiece この点に対して、Hugging Face TokenizersではWordPiece, BPE, SentencePieceなどを含む各種のサブワード分割を学習する実装が同梱されており、Tokenizersライブラリだけでサブワード学習が完結するようになった。 *6 日本語でHugging Face Tokenizersを動かす 検索に微妙にhitしづらいのでドキュメントへのリンクを掲載する。これをざっと読めばライブラリ構成の概要は把握できる。 huggingface.co Hugging Face TokenizersにおけるTokenizerオブジェクトとは、以下の要素からなる各種処理のパイプラインコンテナである。 Encode方向での利用、つまり事前学習モデルに入力可能な トーク ン列を生成する方向では、最 終結 果が Encoding オブジェクトとして得られる。 このデータに元の トーク ン列、モデル特有の 特殊文字 、 トーク ン-id対応、 トーク ン-元の文字列対応といったデータが格納されている。 Encode方向: 文字列 → 事前学習モデル入力可能なEncodingオブジェクト Normalizer: 文字列正規化の前処理( Unicode 正規化や小文字化など) PreTokenizer: 文字列→基本 トーク ン(単語)の変換 Model: 基本 トーク ン→ トーク ン(サブワード)の変換;学習済みvocabularyが必要な箇所 PostProcessor: 最終的なEncodingデータを生成する後処理(BERTの特殊 トーク ンの追加など) Decode方向: トーク ン列 (Encoding)→ 元の文字列 Decoder: トーク ン-元の文字列対応情報を用いて各 トーク ンが位置する元の文字列上の位置情報を復元できる 注意すべき点としては、TokenizersにおけるTokenというのは、いわゆる単語ではなくサブワードのことである。 つまり、従来の日本語 形態素解析 器は、TokenizersパイプラインにおけるPreTokenizerに位置づけられる(英語ではカンマなどを考慮したwhite space分割などに相当)。 PreTokenizerに MeCab を差し込めば良さそうというところまではすんなり理解できるのだが、バックエンドがRustで Python は バインディング 提供という点から、 Python でやるにはこの先が思ったよりすんなり行かなかった。 結論から書くと、PreTokenizerを Python 側では継承定義することができず、既存のTokenizerに対して、以下のような custom PreTokenizerとして注入する必要がある: from tokenizers.implementations import BertWordPieceTokenizer from tokenizers.pre_tokenizers import BertPreTokenizer, PreTokenizer ... # 既存Tokenizer tokenizer = BertWordPieceTokenizer( handle_chinese_chars= False , # for japanese strip_accents= False , # for japanese ) # ユーザー定義のcustom PreTokenizer(MecabPreTokenizer)を注入 tokenizer._tokenizer.pre_tokenizer = PreTokenizer.custom(MecabPreTokenizer()) ... この点はdocumentに書かれておらず、以下のissueからやり方を把握した(多言語対応は現状だとPoC機能に位置付けられている模様): github.com コメントにあるように、custom PreTokenizerの作り方は 中国語custom PreTokenizerのサンプル を参考にした。 ただし、日本語では単語から元の文字列上のspanアラインメントを保持する機能は自分で追加してやる必要がある(中国語 形態素解析 器のjiebaでは便利なことに標準機能のようだ)。 このアラインメント処理がTokenizersの機能要件に入っていることで、 トーク ンと元の文字列の行き来が相当スムーズになるので、この労力は払う価値があると考えている。 この点が便利なケースについては、 前回のエントリ を参照いただきたい。 トーク ンと元の文字列のアラインメント処理については pytextspan を使用した。 ほぼ最小限に近い日本語custom PreTokenizerのサンプル実装は以下のようになる: from typing import List, Optional from MeCab import Tagger import textspan from tokenizers import NormalizedString, PreTokenizedString class MecabPreTokenizer : def __init__ ( self, mecab_dict_path: Optional[ str ] = None , ): """ Construct a custom PreTokenizer with MeCab for huggingface tokenizers. """ mecab_option = ( f "-Owakati -d {mecab_dict_path}" if mecab_dict_path is not None else "-Owakati" ) self.mecab = Tagger(mecab_option) def tokenize (self, sequence: str ) -> List[ str ]: return self.mecab.parse(sequence).strip().split( " " ) def custom_split ( self, i: int , normalized_string: NormalizedString ) -> List[NormalizedString]: """ See. https://github.com/huggingface/tokenizers/blob/b24a2fc/bindings/python/examples/custom_components.py """ text = str (normalized_string) tokens = self.tokenize(text) tokens_spans = textspan.get_original_spans(tokens, text) return [ normalized_string[st:ed] for char_spans in tokens_spans for st, ed in char_spans ] def pre_tokenize (self, pretok: PreTokenizedString): pretok.split(self.custom_split) これで問題は8割ほど解決したが、作成した日本語custom PreTokenizerを備えたTokenizerを使って、サブワード分割学習・ シリアライズ ・ロード、と使ってみようとすると、 custom PreTokenizerはシリアライズできない と怒られる。 この点についてはまだ issue に積まれている状況で、ひとまず動かす目的としては シリアライズ 可能なノンカスタムのPreTokenizerをダミーで代入して シリアライズ することで回避した(custom PreTokenizerは実際にテキストが入力されるタイミングで有効になっていさえすれば良い)。 設定ファイルからワンラインでロードできない状況なので、この点が解決しない限り日本語含む非英語言語に完全対応したとは言えなさそうだと感じているが、これでひとまず学習・推論まで正しく動作するTokenizerが得られた。 以下が、カスタムTokenizerの作成・サブワード分割の学習・モデル シリアライズ の一連の流れを示したコードである。( tokenizer._tokenizer という箇所は、 BertWordPieceTokenizer がTokenizerのラッパーになっている都合上こうなっている。) def train_custom_tokenizer ( files: List[ str ], tokenizer_file: str , **kwargs ) -> BertWordPieceTokenizer: """ Tokenizerの学習・保存処理:custom PreTokenizer付きのTokenizerを学習・保存する。 """ tokenizer = BertWordPieceTokenizer( handle_chinese_chars= False , # for japanese strip_accents= False , # for japanese ) tokenizer._tokenizer.pre_tokenizer = PreTokenizer.custom(MecabPreTokenizer()) # 与えられたコーパスファイル集合からサブワード分割を学習 tokenizer.train(files, **kwargs) # vocab情報に加えて、前処理等パラメータ情報を含んだトークナイザ設定のJSONを保存 # NOTE : Pythonで書かれたcustom PreTokenizerはシリアライズできないので、RustベースのPreTokenizerをダミー注入してシリアライズ # JSONにはダミーのPreTokenizerが記録されるので、ロード時にcustom PreTokenizerを再設定する必要がある。 tokenizer._tokenizer.pre_tokenizer = BertPreTokenizer() tokenizer.save(tokenizer_file) # (Optional) .txt形式のvocabファイルは f"vocab-{filename}.txt" で保存される(外部の処理で欲しい場合) filename = "wordpiece" model_files = tokenizer._tokenizer.model.save( str (Path(tokenizer_file).parent), filename ) return tokenizer def load_custom_tokenizer (tokenizer_file: str ) -> Tokenizer: """ Tokenizerのロード処理:tokenizer.json からTokenizerをロードし、custome PreTokenizerをセットする。 """ tokenizer = Tokenizer.from_file(tokenizer_file) # ダミー注入したRustベースのPreTokenizerを、custom PreTokenizerで上書き。 tokenizer.pre_tokenizer = PreTokenizer.custom(MecabPreTokenizer()) return tokenizer 参考のためにデモ スクリプト 全文を掲載しておく from pathlib import Path from typing import List, Optional import MeCab import textspan from tokenizers import NormalizedString, PreTokenizedString, Tokenizer from tokenizers.implementations import BertWordPieceTokenizer from tokenizers.pre_tokenizers import BertPreTokenizer, PreTokenizer class MecabPreTokenizer : def __init__ ( self, mecab_dict_path: Optional[ str ] = None , ): """Construct a custom PreTokenizer with MeCab for huggingface tokenizers.""" mecab_option = ( f "-Owakati -d {mecab_dict_path}" if mecab_dict_path is not None else "-Owakati" ) self.mecab = MeCab.Tagger(mecab_option) def tokenize (self, sequence: str ) -> List[ str ]: return self.mecab.parse(sequence).strip().split( " " ) def custom_split ( self, i: int , normalized_string: NormalizedString ) -> List[NormalizedString]: """See. https://github.com/huggingface/tokenizers/blob/b24a2fc/bindings/python/examples/custom_components.py""" text = str (normalized_string) tokens = self.tokenize(text) tokens_spans = textspan.get_original_spans(tokens, text) return [ normalized_string[st:ed] for char_spans in tokens_spans for st, ed in char_spans ] def pre_tokenize (self, pretok: PreTokenizedString): pretok.split(self.custom_split) def train_custom_tokenizer ( files: List[ str ], tokenizer_file: str , **kwargs ) -> BertWordPieceTokenizer: """Tokenizerの学習・保存処理:custom PreTokenizer付きのTokenizerを学習・保存する。""" tokenizer = BertWordPieceTokenizer( handle_chinese_chars= False , # for japanese strip_accents= False , # for japanese ) tokenizer._tokenizer.pre_tokenizer = PreTokenizer.custom(MecabPreTokenizer()) # 与えられたコーパスファイル集合からサブワード分割を学習 tokenizer.train(files, **kwargs) # vocab情報に加えて、前処理等パラメータ情報を含んだトークナイザ設定のJSONを保存 # NOTE : Pythonで書かれたcustom PreTokenizerはシリアライズできないので、RustベースのPreTokenizerをダミー注入してシリアライズ # JSONにはダミーのPreTokenizerが記録されるので、ロード時にcustom PreTokenizerを再設定する必要がある。 tokenizer._tokenizer.pre_tokenizer = BertPreTokenizer() tokenizer.save(tokenizer_file) # (Optional) .txt形式のvocabファイルは f"vocab-{filename}.txt" で保存される(外部の処理で欲しい場合) filename = "wordpiece" model_files = tokenizer._tokenizer.model.save( str (Path(tokenizer_file).parent), filename ) return tokenizer def load_custom_tokenizer (tokenizer_file: str ) -> Tokenizer: """Tokenizerのロード処理:tokenizer.json からTokenizerをロードし、custome PreTokenizerをセットする。""" tok = Tokenizer.from_file(tokenizer_file) # ダミー注入したRustベースのPreTokenizerを、custom PreTokenizerで上書き。 tok.pre_tokenizer = PreTokenizer.custom(MecabPreTokenizer()) return tok if __name__ == "__main__" : s = "今日はいい天気だ" with open ( "test.txt" , "wt" ) as fp: fp.write(s) fp.write( " \n " ) fnames = [ "test.txt" ] tokenizer_file = "tokenizer_test.json" settings = dict ( vocab_size= 30000 , min_frequency= 1 , limit_alphabet= 5000 , # 日本語の文字種類数の参考値 ) tokenizer = train_custom_tokenizer(fnames, tokenizer_file, **settings) print (s) # tok = load_custom_tokenizer(tokenizer_file) tok = Tokenizer.from_file(tokenizer_file) print (tok.encode(s).tokens) # ただしく分割できない tok.pre_tokenizer = PreTokenizer.custom(MecabPreTokenizer()) print (tok.encode(s).tokens) print (tok.normalizer) print (tok.pre_tokenizer) print (tok.model) print (tok.decoder) 本記事の主題とはずれるが、この後段で SWIG の MeCab Tagger がpickle化できないと怒られることがあり(MLFlow等)、調べたところ SWIG 版 Tagger をpickle化可能にする処理を書いてくれた方がいたのであわせて共有させていただきます(通常の Tagger をラップすれば良い): tma15.github.io 以上、Hugging Face Tokenizersをなぜ使いたいかの動機と、日本語でHugging Face Tokenizersを動かす際に詰まった点を解説しました。 まだ開発中の色合いが強いライブラリで気軽に使用を勧められる状況とは必ずしも言えませんが、大変多くのユーザーを抱えた開発の盛んなライブラリであるため、近い将来BERT Tokenizer周りのコードはより洗練されたものになっていくことでしょう。 この記事はMNTSQ株式会社の業務時間内に書かれました。 MNTSQ株式会社では契約書解析を高度化する 自然言語処理 エンジニアを募集しています: hrmos.co この記事を書いた人 稲村和樹 自然言語処理 エンジニア。爬虫類が好き。 *1 : といっても2年ほど前のバージョンですが *2 : 日本語に適用したサンプルは知る限りにおいては無かったです。いまだに開発が盛んなライブラリであり、日本語で使う上でのソフトウェア上の課題はまだ残っているという点を予め断っておきます。 *3 : この機能が便利になる場面については、 前回のエントリ で解説しています。ただし、後で見るように日本語でこれを実現するには別のライブラリの力を借りています。 *4 : この点への言及例としては、 京都大学 黒橋研究室で公開されているBERTの注意書きを参照: http://nlp.ist.i.kyoto-u.ac.jp/?ku_bert_japanese#r6199008 *5 : TFIDFやword2vecの学習で コーパス ごとにvocabularyファイルが出力されるのに似ていますが、 コーパス ごとに適応的に決まるサブワード分割のvocabularyファイルは、サブワード分割のモデルと呼ばれます。 *6 : Tokenizers実装WordPieceの日本語のサブワード分割が遅くてつらいですが、学習が速いBPE実装も追加されている模様です: https://github.com/huggingface/tokenizers/pull/165
最近、身近な スモールデータ をさくっと分析してみる機会があったので、過程をまとめてみました。 スモールデータ の解析であっても、前処理、可視化、示唆出しなどデータ分析に必要な所作というのは変わりません。ステップに分けながら紹介したいと思います。 今回はツールに Google Spreadsheetしか使っていないので、ノンエンジニアのビジネスサイドの人であっても同じ分析を回すことができます。 Google Workspace(Gsuite)を使っている企業であれば紹介した生データも取得ができるかと思いますし、30分くらいしかかからないので、試してみると面白いかもしれません。 今回取扱いたいデータは Google Meetのログデータです。COVIDの影響で営業や採用文脈でリモート MTG が増えました。「最近、リモート MTG のちょっとした遅刻、多くない?」という社内のふとした問題提起から、実際にログをみることで「ちょっとした遅刻」がどれくらい発生しているかを可視化してみたいというモチベーションが生まれました 結論としてこんな感じの可視化を得ました 目次 データの取得 生のデータを読む 前処理をする 可視化をする 味わう データの取得  まずは生データの取得からです。今回は Google BusinessのAdminコンソールからMeetのLogデータを出力します。「Report」> 「 Google Meet」 > 「ダウンロード」でダウンロードが可能です。 Google スプレッドシート か csv か選べるようになっています。今回は30分クッキングということで、 スプレッドシート で出力してみます。  データ分析のツールといえばJupyter notebookやRだと思います。が、個人的には Excel やSpreadsheetも使い所によって、すごく強力なツールになると思います。特にスモールサイズのテー ブルデー タを色々いじくりまわす時にはこちらの方がスピードが出ることも多いのではないでしょうか。 生のデータを読む  出力されたファイルを開くと、下記のようなカラムがあることがわかります。(一部抜粋) 日付 イベント名 イベントの説明 会議コード 参加者 ID 組織外の参加者 クライアントの種類 主催者のメールアドレス プロダクト タイプ 期間 通話の評価(5 段階) 参加者名 (その他60項目程度)  今回の分析のモチベーション「みんな時間通りに MTG 入ってる?」という論点から考えると、参加者IDと日付、期間などの情報が特に興味がある情報です。  参加者IDは欠損値も多いことにすぐ気づきます。他の項目と照らし合わせてみると、これは組織外の参加者と MTG をしている時にはこの項目が入ってこないということがわかります。  また、「期間」には整数値が入力されています。これが示す値がどういうものなのかよくわかりません。まずは雑に分布を見てみます。雑にみる時は全部の値をsortしてから、グラフで出してみるのを初手でやることが私は多いです。結果、3600くらいまでの値が多く、そこから少数のレコードでいきなり増加していき、最大値は82649だということがわかります。なんだか3600くらいまでとそれ以上で全然違うメ カニ ズムの値がありそうだなという感覚を受けます。 もうちょっとよくみてみたかったので、 ヒストグラム にしてみました。 バケット サイズは自動にしつつ、上の方に異常値があったので、「異常値のパーセンタイル」に「5%」を設定します。こうすると、上と下の異常値は一つの バケット に入れてくれるので、ぱっと見わかりやすくなります。  こんな感じになりました。これをみると「期間」は「接続時間の秒数」を指しているのではないか、ただし、一部の値はバグっているのではないか?ということに気がつきます。 異常値を除けば3600あたりから急に値が減っています。これは Google Calendar で MTG を入れるとき、ほとんどの MTG は1時間以内しか時間を取らないからだと思われます。 3600秒まで、を見てみると60分あたりの山、45分あたりの山、30分あたりの山、15分くらいまでの山がなんとなくあることがわかります。これは実際にカレンダーで設定しやすい MTG の時間帯と対応している可能性があると思います 90秒以内の異常値は接続不良などがあげられるかと思います。「マイクが調子悪そうだから スマホ で入り直す」みたいな事象はこちらにあげられるかと思います。 5000秒以上のめちゃくちゃ長いレコードについては、実際にどういうレコードか眺めてみます。幸い、自分の入ったことがある MTG もあったので、その時のカレンダーを辿ってみるものの、そんなに長く通話していたわけではありません。ここから上のカウントはもしかするとなんらかの計測バグなのかもしれません なんとなく「画面共有」を使っていた回が多いのかもしれない? という感触は持ちましたがあんまり確信はもてていないです。ここはふかぼっていません。 また「日付」のところにはタイムスタンプがおされていますが、一体開始時刻か終了時刻か、はたまた別のレコードなのかはぱっと見てよくわかりません。しかし、ここは自分の MTG の記憶でたぐると「会議を切断した時刻」であることがわかります。 前処理をする 「みんな時間通りにはいってるか知りたい」というモチベーションからすると「日付」のタイムスタンプから「期間」の秒数を引くことで「入室時刻」がわかるのではないか? とわかります。 Spreadsheetではこんな感じで計算をしました。 想定入室時刻 = <日時が書いてるセル> - TIME(0,0,<秒数のセル>) 想定入室時刻の「分」だけを取得 = MINUTE(<想定入室時刻のセル>) 無事に想定入室時刻が取得できたので、人をキーにしてピボットテーブルを作成し、集計してみます。この際に、上記のゴミデータが混ざらないように下記のフィルタをかけます。 参加者IDが登録されているレコードのみ(社内の人である) 7200秒未満のレコードである(上の外れ値を除去) 1200秒以上のレコードである(quick callなどを除去し、予定された MTG に絞るため) 可視化をする すると上記のような分布が得られます。一つ一つの色が一人のIDと対応しています。横軸が「分」で、縦軸が割合になっています。こうやってみると、一番大きな山が「0分」付近(グラフでいうと左端と右端)に分布していて、次に大きな山が「30分」付近にあることがわかります。これはほとんどの MTG が「X時00分」もしくは「X時30分」から予定されることを考えると非常に自然な分布に見えます。 では、上記のグラフの中で「X時00分」と「X時30分」の前後10分間のみを取得して重ね合わせてみると、「 MTG 前後の入室状況」がより可視化されるのではないか、とわかります。 やってみると。。。 こんな感じになりました。綺麗に「 MTG のちょっと前」に人がたくさん入ろうとしてきているのが見えますね。(中央が MTG 開始時刻) ここから、「 MTG の前後十分間の中で、開始時刻までに入室した率」を人ごとに計算してみます。すると、冒頭で出したような「人物ごとの『間に合った率』」が算出できます。また、それぞれの人が平均よりどれくらい間に合っているのか、どれくらい間に合っていないのかという程度も可視化がされます。 味わう  なんか結果っぽいものが出ましたが、一足跳びに結論に飛びつくのは危険です。データを味わった上で、本当に計算は正しいのか、言えることは何なのか、この分析の限界があるとするとどういうところか、ということを考える必要があります。回りのデータを扱う仕事をする人を見ていると、できる人であればあるほど「結果を正しく疑う」という所作がきちんとできているなと感じます。  データ分析は適当にやっても答えっぽい数値が算出できてしまいます。しかし、それをどこまでどう信頼すべきかの判断はまた別の話です。途中でとんでもない計算ミスをしていても気づかずするっと集計されていき、それをベースに意思決定が発生してしまう、というケースは最悪ですが、割と起きている事象かと思います。  比較的こぢんまりした今回の分析の中でも、実はデータ分析をする人が操作できるレバーというのは比較的多岐に渡ります。今回で言えば、例えば「期間」の異常値をどこまで弾くべきか、集計の際にどういうフィルタを噛ませるべきか、30分の山を足し合わせるべきか、などが挙げられるかと思います。これらのレバーを無意識のうちに分析者の仮説に有利な方に傾けていないかどうか。自分がフェアな分析をできているか、批判的に引いてみてみるというのはとても重要です。  今回の例で言うと、出た結果を見て定性的に不思議に思うところはないかどうか、体感と齟齬があるところはないか、この算出プロセスで結果が狂うとしたらどういうものがあるか、ということを考えていきます。 算出された傾向を何人かに聞いてみて、違和感があまりないことを確認 外れ値の人に聞いてみるのが有効 「0:0x」分からはじまる MTG 等が定例で入っていたりすると不利になりそう アドホック にいきなりはじまったCallなのか、予定されていた MTG なのかの区別はレコード上できないので、 アドホック な MTG を不規則にガンガンやっている人がいれば数値上不利になる可能性がある 回線や機器が不安定だったりして、繋ぎ直しを頻繁にしている人は計算上不利になりそう 上記のようなぶれは生じるので、正確ではないことには注意しつつ、大きな傾向は信頼できそうだとわかった まとめ 身近にあるデータをがちゃがちゃといじるだけであっても色々発見や工夫のしどころはあるものです。小さいデータであっても大きいデータであっても、以下の大きな流れは共通しているかと思います。 まずは一行一行をちゃんと読む 欠損値、外れ値に対して対処する データの分布をみることで性質を掴む ちょくちょく可視化して体感とずれがないか、どういう操作が有効か考える 出てきた結果にすぐ飛びつかない 「どういう限界があるか」、「どこまでの示唆を読み取ってよいか」、「本当に計算はあっているか」をいろんな角度から考えてみる
あなたはDockerに何回入門しただろうか? 何度あのクジラを見て頭を抱えたことだろうか? 今回あなたを「とりあえずDockerを使ってワールドプレスを表示する」ところまで道案内しようと思う。 そう、夢はでっかく世界に羽ばたかないとね。 間違えた、 ワードプレス だ。 Dockerって何 ワードプレスって何 Dockerでワードプレスを動かす ダウンロード インストール Windows macOS Dockerを起動する Windows macOS ワードプレスの設定ファイルを作る テキストエディタを開く Windows macOS 設定を貼り付ける 設定ファイルを保存する Windows macOS ワードプレスを動かす Windows macOS ワードプレスを表示してみよう 注意事項 ワードプレスを止める おわり 仲間募集中! この記事を書いた人 Dockerって何 「仮想化かーそうかー」 「Dockerでどっかーんwww」 Dockerの何たるかも知らずにのほほーんと暮らしている日本人(主語でか)のなんと多いことか。 今、すべての日本人(主語でか)に問います。 「Dockerとは何でしょう」 まぁそんな深遠な事は書かない。 Windows や macOS などのOSがあると思うが、他にも実は Linux という有名なOSがある。 Dockerは Linux をいい感じに仮想化してくれるソフトウェアだ。 Linux は今あなたが見ているこのサイトの裏側など見えにくいところで動いている。 面倒くさいのでそれ以上の説明をここではしない。 ワードプレス って何 「ブログ」はさすがに知っているのではないだろうか。 ブログのシステムがいくつかあるんだけど、そのうち一番メジャーなものが「 ワードプレス 」だ。 素人でも使えちゃうので、色々アレな部分もあるが、とっつきやすさの点で勝っている。 Dockerで ワードプレス を動かす では実際に作業していこう。 以降は記事執筆時点の見た目であり、今後のバージョンアップなどで見た目などが変化する可能性がある。 なお、筆者はダークモードが大好きなので、ところどころ白黒が反転している画面があるが、ご了承いただきたい。 ダウンロード 何はともあれ、まずダウンロードしないと始まらないだろう。 www.docker.com このサイトにアクセスして、右上の「Get Started」をクリック 左下あたりの「Download for Windows 」または「Download for Mac 」をクリックしよう インストール ダウンロードが完了したらインストールしよう。 Windows と macOS で インストーラ ーの見た目が違うので、それぞれの項目を見て欲しい。 ダウンロードされたファイルをダブルクリックしよう。 Windows こんな画面が表示されるので、そのまま右下のOKをクリックしよう。 すると何かが進んでいくが、お茶を飲むかNetfixを眺めるか匍匐前進するかなどしながら待つと良い。 インストールが完了すると以下のような画面が表示される。ボタンを押すのだが、 ここの「restart」はマシンの再起動を示している ので、何か作業中の場合は保存しておくこと。 Windows の再起動が完了すると、こんな通知が出たはずだ。 macOS 書いてあるとおりドラッグ・アンド・ドロップしよう。 それだけ?それだけ。 Dockerを起動する Dockerを利用するには、Dockerを起動しておく必要がある。まぁ当然と言えば当然だ。 Windows Windows の場合はスタートメニューに「Docker Desktop」というメニューが増えているので、それをクリックすると良い。 すると以下のような画面が表示される。左下に黄色いランプで「starting」と表示される。マシンの性能次第であるが、 Windows の場合それなりに時間がかかるようだ。 macOS 「アプリケーション」に「Docker Decktop」が増えているはずなので、それをクリックしよう。 以下のように聞かれるが、「開く」で大丈夫だ。 その後何やら小難しい英語のダイアログが表示されるが、「OK」で大丈夫だ。 続いて「ヘルパーをインストール」というダイアログが表示される。ユーザー名とパスワードは入力して欲しい。 それが終わるとこっそりステータスバーで起動する。アニメーションでコンテナが増えたり減ったりしている時は起動している最中の状態だ。 ワードプレス の設定ファイルを作る では ワードプレス を起動するために、設定ファイルを作ろう。 テキストエディタ を開く Windows Windows では「メモ帳」というアプリを使ってファイルを作る。Wordは使わないように。 スタートメニューから「 Windows アクセサリ」→「メモ帳」とたどるか、検索しよう。 macOS macOS では「テキストエディット」というアプリを使う。「アプリケーション」から「テキストエディット」を探してクリック。 すると以下のようなダイアログが開くので、「新規書類」をクリック。 あと、このままでは余計な情報が含まれてしまうので、「フォーマット」→「標準テキスト」にしておく。 設定を貼り付ける 以降は共通だが、下記内容を貼り付けよう。 version : '3' services : db : image : mysql:5.7 volumes : - db_data:/var/lib/mysql restart : always environment : MYSQL_ROOT_PASSWORD : somewordpress MYSQL_DATABASE : wordpress MYSQL_USER : wordpress MYSQL_PASSWORD : wordpress wordpress : depends_on : - db image : wordpress:latest ports : - "8000:80" restart : always environment : WORDPRESS_DB_HOST : db:3306 WORDPRESS_DB_USER : wordpress WORDPRESS_DB_PASSWORD : wordpress volumes : db_data : まぁここのコピーだが: docs.docker.jp 以下は Windows で貼り付けた後の状態だ。 設定ファイルを保存する Windows ファイルを保存しよう。保存場所はどこでも良いのだが、説明の都合で「ドキュメント」にして欲しい。ファイル名は docker-compose.yml にして欲しい。 docker-compose.yml をコピペして「ファイル名」欄に貼り付けよう。 macOS ファイルを保存しよう。保存場所はどこでも良いのだが、説明の都合で「書類」にして欲しい。ファイル名は docker-compose.yml にして欲しい。 docker-compose.yml をコピペして「ファイル名」欄に貼り付けよう。 少し変わったファイルを使うので、以下のようなダイアログが表示されるが、「".yml"を使用」をクリックする。 ワードプレス を動かす いよいよ ワードプレス を動かそう。 ここから少しだけITエンジニアな画面を見る事になるが、我慢していただきたい。 いずれも「ターミナル」というアプリで作業をする。 以下、コマンドが3つ登場するので、基本的にはコピー&ペーストして欲しい。 Windows の場合は右クリックが「ペースト」になる。 Windows スタートメニューから「 Windows システムツール」→「コマンド プロンプト」とたどるなどして「コマンド プロンプト」を起動する。 すると真っ黒な画面が出てくるので、 cd Documents と入力してリターンキーを押す。 その後、 docker-compose up -d と入力してリターンキーを押す。 こんな感じで何かが流れていくはずだ。 もしかしたらこのタイミングで以下のようなダイアログが出てくるかも知れないが、ここは「アクセスを許可する」をクリックする(でないとDockerが動かない)。 処理が完了すると、 C:\Users\ユーザー名\Documents> という表示が出て止まる。 macOS 「アプリケーション」→「ユーティリティ」→「ターミナル」を起動する。 すると真っ黒な画面が出てくるので、 cd Documents と入力してリターンする。 その後、 docker-compose up -d と入力してリターンキーを押す。 すると以下のように何かが動いていくはずだ ワードプレス を表示してみよう 実は先程の作業で ワードプレス はもう起動している。 早速見に行ってみよう。 まずDocker Desktopアプリを開こう。 Windows はアプリを開くと出てくるが、 macOS の場合はステータスバーから「 Dashboard 」を開く。 するとこういう感じの状況になっているはずだ(緑が今正に動いている「コンテナ」だ!) documents_wordpress_1 にマウスカーソルを合わせると、右側にこんな感じのアイコンが出てくるので、一番左の OPEN IN BROWSER をクリックしよう。 すると ブラウザー が開いて、以下のような画面が表示されたはずだ。 おめでとう! ワードプレス が無事に起動した! 後は適当に設定しながら(設定したパスワードとユーザー名は忘れないように!)設定を完了させよう。 左上の(設定したサイト名)→「サイトを表示」をクリックしてみよう。 これがあなたのブログだ! 注意事項 さて、これで ワードプレス が動いたわけだが、これは あなたのマシンの上でだけ動いている ものだ。これをワールドにプレスするには、他にもいくつかする事があるのだが、このブログ記事の目的は達成されたので、その話はまた今度機会があればするかも知れない。 ワードプレス を止める Windows なら「コマンド プロンプト」、 macOS なら「ターミナル」で docker-compose down と入力してリターンキーを押そう。すると以下のようになるはずだ。 これでお掃除は完了だ。 おわり おわり。 簡単だったと思うが、そういう事だ。 次回があればもう少し何か書くかも知れないが、予定は常に未定だ。 仲間募集中! このエントリーは素人でも(非エンジニアでも)分かるように書いてみたつもりだ。 弊社では非エンジニアとのコラボレーションを重要視しているので、このようなスキルは重要なのだ。 弊社にはリーガルチームがあるが、リーガルチームはリーガルの事をエンジニアに分かるように説明するよう努力するし、エンジニアチームもエンジニアリングの事をリーガルに分かるように説明するよう努力する。 そんな文化に興味がある方はぜひともエントリーいただきたい。 と、もっともらしい事を書いて締めの言葉としたい。 この記事を書いた人 Yuki Nishimura 雑食系エンジニア
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社で取締役として 機械学習 ・ 自然言語処理 に関わるもろもろをやっています。好きな食べ物は担々麺です。