SQLで始める自然言語処理
こちらの記事はRecruit Engineers Advent Calendar 2020の24日目の記事です。メリークリスマス!
仕事の分析で使うデータはほとんどがBigQueryに保存されているため、基本的な分析作業の多くはBigQueryでSQLを書くことで行なっています。
BigQueryでテキストデータを扱おうと思うとSQLではできない or 取り回しが悪いことも多く、一度Pythonでスクリプトを書いてその結果を再度BigQueryのテーブルに格納し、Joinして分析に使うということをしていました。
しかしこのやり方だとテキストデータを分析したいときは毎回Pythonのコードを書きにいかねばならず、またPythonでのテキスト処理も決して早いとはいえず、せっかくBigQueryでさくさく分析しているのにどうしてもテキスト処理に部分が作業時間のボトルネックになってしまいます。
SQLでテキスト処理を行うことができればいちいちPythonスクリプトを書くという面倒事に手を焼くこともなく、またPythonは書けないがSQLは書けるという人なら誰でもテキスト処理を行うことができるようになります。近年ではBigQueryやAmazon Redshiftなどの高度なデータウェアハウスが整備されていたり、SQLを使った分析技術に関する書籍が多数出版されていることもあり、SQLなら書けるという人も少なからずいるでしょう。
今回はこれらの要望を叶えるために、SQLを使って自然言語処理を行うテクニックを紹介します。
データ
今回も例によってLivedoorのブログコーパスを使用していきます。
データは「id」「ur」「write_date」「media」「title」「article」の6つのカラムに分けて格納しています。
前処理
まずはテキスト処理の基本、前処理を行なっていきます。
自然言語処理で行われているポピュラーな前処理をSQLで実現していきます。
正規表現による除去
基本中の基本の処理ですね。
BigQueryでは regexp_replace
という関数が用意されており、それを使って正規表現にマッチしたテキストを置換することができます。
with texts as ( select * from unnest(['昨日の<span color="red">夜</span>は非常に強い雨で<br>疲れてしまった', 'なんとこれは<b>衝撃の真実</b>だったわけです']) as strings ) select regexp_replace(strings, '<(".*?"|\'.*?\'|[^\'"])*?>', '') as clearned from texts
正規化
濁音のかなを表現する際、Unicodeには合成済み文字(だ)と基底文字+結合文字(た+゛)の2種類が存在します。
一見すると違いが分からない(実行する環境によってはほぼ同じに見える)ですが、文字列の一致判定をするとUnicodeが異なるため判定をすり抜けてしまい惨劇を招くことがあります。
これを回避するには文字列に対してUnicodeの正規化を行えばOKです。
BigQueryでは normalize
という関数が用意されています。
with texts as ( select 'カピバラ' as a, 'カピバラ' as b ) select a, b, a = b as raw, a = normalize(b, NFKC) as normalized from texts
単語分割
日本語の自然言語処理を行う場合にはテキストを形態素と呼ばれる単位に分割するのが一般的です。
これはMecabやSudachiといった形態素解析エンジンを使って行われることがほとんどです。
しかしSQLでこれらのミドルウェアやPythonコードを動かすことができません。
そこで使うのがBigQueryの User Defined Function(UDF)機能です。
User Defined Function機能とはBigQueryが提供している機能のひとつで、これを使えば外部からSQLまたはJavaScriptを読み込んで関数として実行することができます。
つまりJavaScriptに処理を書き込めばSQLの中で何でも実行することができるわけです!
JavaScriptで単語分割を行うライブラリとしてTinySegmenterがあります。
ただしこれはニュース記事をもとに学習しているため、他のドメインのテキストの単語分割では精度が出しづらい可能性があります。
そんなときのために、TinySegmenterを再学習させることができるTinySegmenterMakerというツールがあるので、今回はこれを使ってブログドメイン向けの形態素解析器を作成しました。
長いので一部のみ記載
// TinySegmenter 0.1 -- Super compact Japanese tokenizer in Javascript // (c) 2008 Taku Kudo <taku@chasen.org> // TinySegmenter is freely distributable under the terms of a new BSD licence. // For details, see http://chasen.org/~taku/software/TinySegmenter/LICENCE.txt (function(global) { global.TinySegmenter = TinySegmenter; var default_model = {BC1:... return result; }; })(this); const tinySegmenter = new TinySegmenter(); function segment(x){ return tinySegmenter.segment(x); }
このJavaScriptをGoogle Cloud Storageに配置し、そのファイルをUDFの記法で読み込めばSQLの中で形態素解析を行うことができるようになります。
create temporary function segment(x string) returns array<string> language js as """ return segment(x) """ options ( library="gs://ymym-example-project/udf/string/tinysegmenter_retrain.js" ); with segmented as ( select * from unnest(['昨日の夜は非常に強い雨で疲れてしまった', 'なんとこれは衝撃の事実だったわけです']) as strings ) select segment(strings) as tokens from segmented
ストップワード除去
テキストを扱う上であまり大きな意味を持たないことが多い語をストップワードと呼びます。
日本語の場合、「は」や「の」、「あっち」のような助詞や指示語などが該当することが多いです。
ストップワードについては公開されている日本語ストップワード辞書があるのでそれをテーブルにして単語分割結果の各tokenがその辞書の中に存在するかでチェックをかければOKです。
create temporary function segment(x string) returns array<string> language js as """ return segment(x) """ options ( library="gs://ymym-example-project/udf/string/tinysegmenter_retrain.js" ); # 形態素解析結果 with segmented as ( select id, segment(article) as seg from `ymym-example-project.work.livedoor_corpus` ), token_base as ( select id, token from segmented, unnest(seg) as token ) select * from token_base where token not in (select words from `ymym-example-project.work.stop_words_ja` ) # ストップワードのテーブル
頻度による除去
ストップワードの他にも特定のドメインでは当たり前のように出てくる単語は分析の際に邪魔になってしまうこともあります。
なので分析の際に頻度が高すぎる/低すぎる語を除外することがあります。
これは頻度順TopNの除外と、頻度がM以下の語を除外といった形で実現されます。
# 最低頻度 create temporary function min_freq() AS (3); # 除外するTopN create temporary function top_n() AS (200); token_base as ( select id, token from segmented, unnest(seg) as token ), # 最低頻度に達していないtokenをフィルタリングするテーブル token_filter as( select token from token_base group by token having count(1) <= min_freq() ), # 頻度上位N個の除外する単語 top_n_filter as ( select token from ( select token, row_number() over (order by count(1) desc) as seq from token_base group by token ) where seq <= top_n() ), # tokenのフィルタリング filtered_token as( select kuchikomi_cd, token from token_base where token not in (select token from token_filter) and token not in (select token from top_n_filter) and token not in (select word from `ymym-example-project.work.stop_words_ja` )
ストップワードのフィルターと合わせることで分析に不必要な単語を除外することができました。
TF-IDF
TF-IDFは文書中の単語の重要度を計算する手法です。
細かい説明は省きますが、文書での単語の出現比率を表すTerm Frequency、全文書での単語の出現する文書の比率の逆数を表すInverse Document Frequencyをかけあわせることで計算できます。
通常のTF-IDFでは文書の長さでスコアが変化しやすいため、文書の長さで正規化した値を用いることが多いです。
TF-IDFの計算は単純に頻度を数え上げればできるので、ここまででフィルタリングした結果を元にTF-IDFを計算していきます。
Term Frequency
まずはTF値を計算します。
# tf計算 tf_base as( select id, token, count(1) as cnt, from filtered_token group byid, token ), term_in_doc as ( select id, count(1) as len from filtered_token group byid ), tf_table as ( select id, token, (cnt / len) as tf from tf_base inner join term_in_doc using (id) ) select * from tf_table
Inverse Document Frequency
続いてIDF値の計算です。
# idf計算 df_base as ( select distinct id, token from filtered_token ), doc_len as ( select count(1) as doc_count from ( select distinct id from filtered_token ) ), df_agg as ( select token, count(*) as cnt from df_base group by token ), df_table as ( select token, log(doc_count / cnt) + 1 as idf from df_agg cross join doc_len ) select * from df_table
L2正規化済みTF-IDF
ではここまでで求めたTF値とIDF値を組み合わせてTF-IDF値を計算しつつ、文書の長さの影響を緩和するためにL2正規化を行います。
tfidf_table as ( select id, tf_table.token, tf * idf as tfidf from tf_table cross join df_table where tf_table.token = df_table.token ), # L2正規化 l2_normalized_tfidf as( select id, token, tfidf / sqrt(sum(tfidf * tfidf) over (partition by id)) as l2_tfidf from tfidf_table ) select * from l2_normalized_tfidf
TF-IDFとCos類似度を使った類似文書検索
ここまでで計算したTF-IDF値を使って文書間の類似度をCos類似度で計算します。
先ほど作成した l2_normalized_tfidf
テーブルはその文書に出現する単語のみを保持しているので2文書間のCos類似度を測る時は双方の文書に出現する単語のTF-IDF値の積を取って全て足し合わせればOKです。
またL2正規化をしてあるため、Cos類似度の分母の計算は必要ありません。
# TF-IDFベクトルのCosine類似度を計算 # 片方の文書にしか出現していないtokenは、もう片方のtfidf値が0なので考慮する必要がない # 双方に出現するtokenのtfidf値の積だけ考えればよい cos_similarity as ( select distinct a.id as a_id, b.id as b_id, sum(a.l2_tfidf * b.l2_tfidf) over (partition by a.id, b.id) as cos_sim, from l2_normalized_tfidf as a cross join l2_normalized_tfidf as b where a.token = b.token and a.id <> b.id ) select a_id, b_id, cos_sim, a_article.media, b_article.media, a_article.article, b_article.article from ( select a_id, b_id, cos_sim, row_number() over (partition by a_id order by cos_sim desc) as seq from cos_similarity ) # 文書の中身を確認するためjoinする inner join `ymym-example-project.work.livedoor_corpus` as a_article on a_id = a_article.id inner join `ymym-example-project.work.livedoor_corpus` as b_article on b_id = b_article.id # 類似度が高い上位5件を取得 where seq <= 5 order by a_id, seq
結果を見てみると、「dokujo-tsushin」の文書aに対して、上位の類似文書も「dokujo-tsushin」であり、前処理の効果もありうまく特徴量抽出ができているようです。
また処理時間についても、7400件弱のブログの本文のテキストに対し、前処理〜単語分割〜TF-IDF計算〜Cos類似度計算の全てを行っても300秒強程度しかかからず、かなりの速度で一連の作業を終えられているのではないかと思います。
最後にSQLの全体像をまとめておきます。
# tinysegmenterによる単語分割 create temporary function segment(x string) returns array<string> language js as """ return segment(x) """ options ( library="gs://ymym-exapmle-project/udf/string/tinysegmenter_retrain.js" ); create temporary function min_freq() AS (3); create temporary function top_n() AS (200); # 形態素解析結果 with segmented as ( select id, segment(article) as seg from `ymym-example-project.work.livedoor_corpus` ), token_base as ( select id, token from segmented, unnest(seg) as token ), # 最低頻度に達していないtokenをフィルタリングするテーブル token_filter as( select token from token_base group by token having count(1) <= min_freq() ), # 頻度上位N個の除外する単語 top_n_filter as ( select token from ( select token, row_number() over (order by count(1) desc) as seq from token_base group by token ) where seq <= top_n() ), # ストップワードなどを含めたtokenのフィルタリング filtered_token as( select id, token from token_base where token not in (select token from token_filter) and token not in (select token from top_n_filter) and token not in (select words from `ymym-example-project.work.stop_words_ja` ) # ストップワードのテーブル ), # tf計算 tf_base as( select id, token, count(1) as cnt, from filtered_token group by id, token ), term_in_doc as ( select id, count(1) as len from filtered_token group by id ), tf_table as ( select id, token, (cnt / len) as tf from tf_base inner join term_in_doc using (id) ), # idf計算 df_base as ( select distinct id, token from filtered_token ), doc_len as ( select count(1) as doc_count from ( select distinct id from filtered_token ) ), df_agg as ( select token, count(*) as cnt from df_base group by token ), df_table as ( select token, log(doc_count / cnt) + 1 as idf from df_agg cross join doc_len ), tfidf_table as ( select id, tf_table.token, tf * idf as tfidf from tf_table cross join df_table where tf_table.token = df_table.token ), # L2正規化 l2_normalized_tfidf as( select id, token, tfidf / sqrt(sum(tfidf * tfidf) over (partition by id)) as l2_tfidf from tfidf_table ), # TF-IDFベクトルのCosine類似度を計算 # 片方の文書にしか出現していないtokenは、もう片方のtfidf値が0なので考慮する必要がない # 双方に出現するtokenのtfidf値の積だけ考えればよい cos_similarity as ( select distinct a.id as a_id, b.id as b_id, sum(a.l2_tfidf * b.l2_tfidf) over (partition by a.id, b.id) as cos_sim, from l2_normalized_tfidf as a cross join l2_normalized_tfidf as b where a.token = b.token and a.id <> b.id ) select a_id, b_id, cos_sim, a_article.media, b_article.media, a_article.article, b_article.article from ( select a_id, b_id, cos_sim, row_number() over (partition by a_id order by cos_sim desc) as seq from cos_similarity ) tmp inner join `ymym-example-project.work.livedoor_corpus` as a_article on a_id = a_article.id inner join `ymym-example-project.work.livedoor_corpus` as b_article on b_id = b_article.id where seq <= 5 order by a_id, seq
Embeddingを使った類似文検索
TF-IDFみたいな疎ベクトルって古くない?という方のためにも、近年主流である文章を密なベクトル(Embedding)に変換して類似文書を検索していくやり方も紹介していきます。
先ほどまでのTF-IDFでは、2つの文書間の単語で、単語の表層が一致していないと類似度の計算ができませんでしたが、例えば密ベクトルの中に「ゆったり」と「のんびり」が意味的に近いということをエンコードできれば、単語の表層が一致していなくても意味的に類似している文書を探しやすくなることが期待できます。
しかし、文書のEmbeddingを得るのはTF-IDFのように単語分割した結果を集計するだけではできません。
Embeddingを得るための何らかのモデルに単語分割した単語列を入力する必要があります。
今回はBigQuery MLを使って文書のEmbeddingを取得していきます。
BigQuery ML
BigQuery MLはBigQueryが提供している機能のひとつで、SQLを使ってモデルを学習させたり、学習させたモデルを使った推論などを行うことができます。
学習できるモデルは線形回帰、ロジスティック回帰、k-means、行列分解などメジャーな手法がカバーされています。
また自分でモデルを学習させる他にも、TensorFlowモデルのインポートにも対応しており、今回はそちらを使ってEmbeddingを得るためのモデルを外部からBigQueryに持ち込みます。
TensorFlow HubからNNLMを持ち込む
TensorFlow Hub(TF Hub)というTFの学習済みモデルをホスティングするサービスがあり、今回はそこから日本語のNeural Network Language Model(NNLM)を使って日本語文書のEmbeddingを計算します。
BigQuery MLでTFのモデルを読み込むにはTFのモデルをSavedModel形式で保存している必要があるので、TF Hubからモデルをダウンロードし、そのモデルをSavedModel形式で保存してGCSに持ち込みます。
import tensorflow_hub as hub import tensorflow as tf class NNLM(tf.Module): def __init__(self, model): self.model = model @tf.function(input_signature=[tf.TensorSpec(shape=None, dtype=tf.string)]) def __call__(self, x): result = self.model(x) return {"scores": result } # TF Hubからモデルのダウンロード embed = hub.KerasLayer("https://tfhub.dev/google/nnlm-ja-dim50-with-normalization/2", input_shape=[], dtype=tf.string) # SavedModel形式で保存できるようにクラスでラップする model = tf.keras.Sequential([embed]) module = NNLM(model) call_output = module.__call__.get_concrete_function(tf.TensorSpec(None, tf.string)) # nnlm/以下に保存されたファイルをGCSに保存する tf.saved_model.save(module, "./nnlm", signatures={'serving_default': call_output})
これでHubからモデルをダウンロードしてBigQuery MLで読み込めるようになりました。
BigQuery MLで文書をEmbedding化
今回使用するNNLMは文書を単語分割しそれらを半角スペースで繋げたものを入力として想定しているので、文書を変換してからモデルに入力します。
BigQuery MLでのモデルのロードには専用の記法があり、それにモデルを保存してGCSのバケットとディレクトリを指定すればモデルをロードできます。
# BQ MLでtensorflow hubにあるnnlmモデルを保存したものを読み込む create or replace model `ymym-example-project.work.imported_tf_model` options (model_type='tensorflow', model_path='gs://ymym-example-project/bqml/use-multi/nnlm/nnlm/*'); # tinysegmenterによる単語分割 create temporary function segment(x string) returns array<string> language js as """ return segment(x) """ options ( library="gs://ymym-exapmle-project/udf/string/tinysegmenter_retrain.js" ); # 形態素解析結果 with segmented as ( select id, segment(article) as seg from `ymym-example-project.work.livedoor_corpus` ), # BQ MLでテキストをembeddingに変換 # テキストはtokenを半角スペースでつないだものを入力とする nnlm_predict as ( select * from ml.predict(model `ymym-example-project.work.imported_tf_model`, ( select # TensorFlowのモデルで受け取る変数名: xに命名しなおす id, array_to_string(seg, ' ') as x from segmented ) ) ) select * from nnlm_predict
うまくいけば、このようにベクトルの要素がrepeatedなカラムに格納された形で取得できます。
Cos類似度を用いた類似文書検索
文書毎の埋め込みベクトルを得ることができたので、あとはTF-IDFのときと同じようにCos類似度を求めていくだけです。
# 文書A, 文書B, Aのベクトル, Bのベクトルというテーブルを作る doc_vector as( select A.id as doc_a, B.id as doc_b, A.scores as score_a, B.scores as score_b, from nnlm_predict as A, nnlm_predict as B ), # コサイン類似度を計算 doc_similarity as( select doc_a, doc_b, ( select sum(k * u) / (sqrt(sum(k * k)) * sqrt(sum(u * u))) from unnest(score_a) as k with offset as i, unnest(score_b) as u with offset as j where i = j ) as cos_sim from doc_vector order by doc_a, doc_b ) select doc_a, doc_b, cos_sim, rank, article_a.media as media_a, article_b.media as media_b, article_a.article as article_a, article_b.article as article_b from ( select doc_a, doc_b, cos_sim, row_number() over (partition by doc_a order by cos_sim desc) as rank from doc_similarity where doc_a <> doc_b ) inner join `ymym-example-project.work.livedoor_corpus` as article_a on doc_a = article_a.id inner join `ymym-example-project.work.livedoor_corpus` as article_b on doc_b = article_b.id where rank <= 5 order by doc_a, rank
類似文書検索結果を見てみます。
こちらでも先ほどと同じように「dokujo-tsushin」の文書に対して「dokujo-tsushin」の文書が類似度上位に来ています。
またTF-IDFのときとは違って、中に出てくるトピックは違うが女性向けの記事であるという点で近い内容であることが分かります。
こちらも最後にSQLの全体像を載せておきます。
# BQ MLでtensorflow hubにあるnnlmモデルを保存したものを読み込む create or replace model `ymym-example-project.work.imported_tf_model` options (model_type='tensorflow', model_path='gs://ymym-example-project/bqml/use-multi/nnlm/nnlm/*'); # tinysegmenterによる単語分割 create temporary function segment(x string) returns array<string> language js as """ return segment(x) """ options ( library="gs://ymym-exapmle-project/udf/string/tinysegmenter_retrain.js" ); # 形態素解析結果 with segmented as ( select id, segment(article) as seg from `ymym-example-project.work.livedoor_corpus` ), # BQ MLでテキストをembeddingに変換 # テキストはtokenを半角スペースでつないだものを入力とする nnlm_predict as ( select * from ml.predict(model `ymym-example-project.work.imported_tf_model`, ( select # TensorFlowのモデルで受け取る変数名: xに命名しなおす id, array_to_string(seg, ' ') as x from segmented ) ) ), # 文書A, 文書B, Aのベクトル, Bのベクトルというテーブルを作る doc_vector as( select A.id as doc_a, B.id as doc_b, A.scores as score_a, B.scores as score_b, from nnlm_predict as A, nnlm_predict as B ), # コサイン類似度を計算 doc_similarity as( select doc_a, doc_b, ( select sum(k * u) / (sqrt(sum(k * k)) * sqrt(sum(u * u))) from unnest(score_a) as k with offset as i, unnest(score_b) as u with offset as j where i = j ) as cos_sim from doc_vector order by doc_a, doc_b ) select doc_a, doc_b, cos_sim, rank, article_a.media as media_a, article_b.media as media_b, article_a.article as article_a, article_b.article as article_b from ( select doc_a, doc_b, cos_sim, row_number() over (partition by doc_a order by cos_sim desc) as rank from doc_similarity where doc_a <> doc_b ) inner join `ymym-example-project.work.livedoor_corpus` as article_a on doc_a = article_a.id inner join `ymym-example-project.work.livedoor_corpus` as article_b on doc_b = article_b.id where rank <= 5 order by doc_a, rank
BigQuery MLの注意点
BigQuery MLを使う上で注意しなくてはならないのがインポートできるモデルのサイズ上限です。
2020/12現在ののBigQuery MLでのTF Modelインポート機能では、モデルのサイズは250MBに制限されています。
このサイズは意外とシビアで、今回使ったNNLMは文書を50次元のベクトルに埋め込むモデルだったのですが、これを128に埋め込むモデルにするとサイズオーバーでインポートすることができません。
そのため近年流行りのBERTのような巨大モデルはもちろん、Universal Sentence Encoder(293MB...)のようなTF Hubで提供されている有用なモデルも今の所読み込むことができません。
Googleさん、モデルサイズの上限をもう少しだけ緩和して頂けないでしょうか...
まとめ
今回はSQLのみを使って自然言語処理の前処理、そしてTF-IDFと文書のEmbeddingを使った類似文検索を行うやり方を紹介しました。
これらをSQLだけで完結させることができれば、テキスト処理のたびにわざわざPythonを書かなくてもよくなるので、分析のスピードが上がるのではないでしょうか。
またBigQueryは内部で処理を分散して非常に高速に行ってくれるため、通常は非常に時間がかかるテキスト処理もBigQueryでやれば短い時間で済ますことができます。
SQL NLPerが今後増えていくことを楽しみにしています。