はじめに
プロジェクトを始める際に「前回の反省を生かして今度こそ柔軟で、わかりやすいアプリケーションを作るぞ!」と思うのは皆様同じかと思います。
が、Inputがないと結局アイディアもわかずイケてるアプリが作れずまた反省する羽目に…というのは誰しも感じたことがあるのではないでしょうか?(僕だけ???)
そういった時に役立つのはOSSかと思います。
我々より遥かに高みにいる技術者の考えた構造を理解し、パクる 学ぶ事により、より良いソフトウェアが生まれるというものです。
今回はchariotというOSSを題材に、私がイケてる!と思った箇所を記載したいと思います。
chariotとは
chariotとはchakki-worksさんが公開しているNLPの前処理ツールです。
https://github.com/chakki-works/chariot
以下のような機能を持ち、自然言語処理をより効率的に扱えるようになるものです
- 学習済みデータセットのDL
- 各種前処理機能の用意
- Scikit-learnのPipelineライクに前処理を自由に組み合わせ可能
イケてるポイント
私が素晴らしいと感じたのは以下のコード部分です。
形態素解析やストップワードの除去などの前処理を追加し、Trainを実施していますね。
注目すべきはその自由さで、stackメソッドに各種Transformerを放り込むだけで前処理定義が完了しています。
preprocessor = Preprocessor()
preprocessor\
.stack(ct.text.UnicodeNormalizer())\
.stack(ct.Tokenizer("en"))\
.stack(ct.token.StopwordFilter("en"))\
.stack(ct.Vocabulary(min_df=5, max_df=0.5))\
.fit(train_data)
こうやっていろいろな処理を組み合わせるのに、1メソッドで自由に色々な処理を追加・変更できる構造になっていれば非常に便利だなあと。
しかもそれぞれの前処理クラスに依存がないため単体テストも書きやすいと一石二鳥です。
やりがちなアンチパターン
このような前処理を特定プロジェクトで実装する"だけ"であれば、特に難しい話ではないかと思います。
ただ何も考えないと以下のようなアンチパターンに陥るがちではないかと。
硬直化 - 自由に入れ替えれないじゃん
一番最悪なのは、このように処理ループを縦にずらっと書いただけのものです。
for s in doc:
# 形態素解析
tokens = t.tokenize(s)
for token in tokens:
# stopword除去
....
悪い点は言うまでもないですが
- 外部からの柔軟な変更ができない
- 処理の追加・入れ替え時のコストが高い
- 各前処理だけの単体テストが難しい
などなど多岐にわたります。
ただ何も考えないとこうなってしまうことも多くよく見るだけに悩ましいですね。
外部からの変更も難しく、改修も厳しいことから「硬直化」してしまい死んだコードになってしまいがちです。
setter地獄 - これを追加するメソッドは???
このようあn硬直化を回避するべく外部からsetできるようなメソッドを用意することもあるかもしれません。
preprocessor.setTokenizer(...)
preprocessor.setNormalizer(...)
Javaあたりだと結構見かけるコードですね。
硬直化にくらべると単体テストもやりやすく大分良くなっているかと思います。
ただこれもchariotの構造と比較すると
- 使う側はsetterを都度調べ、各Transformerがセットできるものを調査してながら使わなくては行けない
- 新規Transoformer追加時にsetterを用意する必要があり若干面倒
あたりが気になるところかなあと思ってしまいます。
見習う箇所
さてこれまでアンチパターンの悪い点を述べていましたが、これからはchariotがこのような点をいかに回避し、イケてるアプリアーキになっているかをご紹介したいと思います。
stackメソッド
各種Transformerのセット
各種TransformerクラスをセットするのはPreprocessorクラスという大本(?)のクラスとなります。
このinitで各種Transformerの格納形態を定義しています。
MecabやJanomeなどの形態素解析は単一であるためNoneに、StopwordFilterのように複数追加できるように()で初期化していますね。
def __init__(self, tokenizer=None,
text_transformers=(), token_transformers=(),
vocabulary=None, other_transformers=()):
self.tokenizer = tokenizer
if isinstance(self.tokenizer, str):
self.tokenizer = Tokenizer(self.tokenizer)
self.text_transformers = list(text_transformers)
self.token_transformers = list(token_transformers)
self.vocabulary = vocabulary
self.other_transformers = list(other_transformers)
実際に外部からTranformerをセットするstackメソッドでは、isintanceメソッドを利用して、initで定義した各変数にこれを入れていきます。
def stack(self, transformer):
if isinstance(transformer, Tokenizer):
self.tokenizer = transformer
elif isinstance(transformer, (TextNormalizer, TextFilter)):
self.text_transformers.append(transformer)
elif isinstance(transformer, (TokenFilter, TokenNormalizer)):
self.token_transformers.append(transformer)
elif isinstance(transformer, Vocabulary):
self.vocabulary = transformer
elif isinstance(transformer, (BaseEstimator, TransformerMixin)):
self.other_transformers.append(transformer)
else:
raise Exception("Can't append transformer to the Preprocessor")
return self
このように
- コンストラクタで格納場所を定義
- 共通でセットするメソッド内ではインスタンスの型を見つつ、決まった場所に格納していく
としていくことにより、一つのインターフェイスだけで積み上げることができそうですね。
Trasnsformerの一挙取得
このように変数に格納したものを利用する場合、それぞれから取り出してfit,transformしていくとすると変数がが増えた場合に影響が大きくなります。
そこでセットしたTransformerを一挙に取得できるようなメソッドをchariotでは用意しています。
propertyデコレータを使い、TrasformerをReadすること明示した上で、全Transformerを返すようにしています。
@property
def _transformers(self):
transformers = list(self.text_transformers)
if self.tokenizer:
transformers += [self.tokenizer]
transformers += self.token_transformers
if self.vocabulary:
transformers += [self.vocabulary]
transformers += self.other_transformers
return (
(self._to_snake(t.__class__.__name__) + "_at_{}".format(i), t)
for i, t in enumerate(transformers)
)
このように各変数に格納したものを直接利用するのではなく、それをまとめて返すものを用意することによって影響を局所化して変更を容易にすることを実現していますね。
実行
あとはこのTranformerを実行していくだけですね。
chariotは
Transformers are implemented by extending scikit-learn Transformer.
とあるようにscikit-learn準拠なので各種Transformerはfit, transform, fit_transformを持っています。
そのため実行も各Transformerのfit, trasform, fit_transformを実行していくことになります。
fitは若干複雑なので割愛し、ここではfit_tranformを紹介します。
def fit_transform(self, X, y=None):
self._validate_transformers()
Xt = X
for name, t in self._transformers:
Xt = t.fit_transform(Xt)
return Xt
このようにpropertyデコレータで定義された_transformersから各種Transformerを取得し、保有しているfit_transformを実行していくだけで実行可能となります。
それぞれのTransformerの役割は違うものの、実行には同じメソッドを用意することによって実行も簡単になる工夫がされていますね。
Tranformerクラス
Scikit-learn準拠
先述して通りTransformerはscikit-learn準拠であり、同じメソッドを保有しています。
これを実現するためにTransformerクラスはBaseEstimator, TransformerMixinを継承しています。
これはsckit-learnのPipelineに適用するためのベースクラスであり、これを継承することにより共通メソッドを持つことになります。
以下はTransformerのベースクラスの一つであるBasePreprocessorクラスです。
from sklearn.base import BaseEstimator, TransformerMixin
from chariot.util import apply_map
class BasePreprocessor(BaseEstimator, TransformerMixin):
def __init__(self, copy=True):
self.copy = copy
def apply(self, elements):
raise Exception("You have to implements apply")
def fit(self, X, y=None):
return self
def transform(self, X):
return apply_map(X, self.apply, inplace=(not self.copy))
このように共通メソッドを持つために継承関係は持たせることはよくありますね。
ただここではscikit-learnで用意されたものを利用することにより
- scikit-learnユーザでも理解しやすいインターフェイスを持つ
- scikit-learnのPreporcesserもMixして実行できるようになる
というメリットをもたせることに成功しています
さいごに
chariotは単なるユーザだったのですが、ふとしたきっかけで中身を読んでその素晴らしさに感嘆したため記事を書かせていただきました。
上記の様な構造をもたせることにより、自分が担当するプロジェクトでも柔軟で使いやすいものが提供できるかなと思います
なお今回は内部構造から学ぼう!という内容でしたが、chariot自体もNLPの前処理をするのに非常に使いやすく便利なOSSだと感じています。
ぜひ皆様chariotを使ってその便利さを感じてみてください!