hive上で文字列の類似度を出してみる。用途としては表記揺れを検出したいとか。
この前使った駅データ.jpの路線名データを利用してみる。下記のようなデータ。
line_cd line_name 1001 中央新幹線 1002 東海道新幹線 1003 山陽新幹線 1004 東北新幹線 1005 上越新幹線 1006 上越新幹線(ガーラ湯沢支線) 1007 山形新幹線 1008 秋田新幹線 1009 北陸新幹線 1010 九州新幹線 ・ ・
line_nameの類似度を取って、一番似ている路線名を引っ張ってみる。
ngramsという関数がhiveに用意されているので、それを使う。
ngrams(array, n, top-k)
まず split(station_name, '') で文字列を1文字ずつの配列に分割し、bi-gramで取りたいのでn=2に,文字列長が全部収まるように k=50にしてクエリを投げてみる。
SELECT line_cd, line_name, ngrams(split(line_name, ''), 2, 20) line_name_ngram FROM test_lines GROUP BY line_cd, line_name
1001 中央新幹線 [[["","中"],1],[["中","央"],1],[["央","新"],1],[["幹","線"],1],[["新","幹"],1],[["線",""],1]] 1002 東海道新幹線 [[["","東"],1],[["幹","線"],1],[["新","幹"],1],[["東","海"],1],[["海","道"],1],[["線",""],1],[["道","新"],1]] 1003 山陽新幹線 [[["","山"],1],[["山","陽"],1],[["幹","線"],1],[["新","幹"],1],[["線",""],1],[["陽","新"],1]] 1004 東北新幹線 [[["","東"],1],[["北","新"],1],[["幹","線"],1],[["新","幹"],1],[["東","北"],1],[["線",""],1]] 1005 上越新幹線 [[["","上"],1],[["上","越"],1],[["幹","線"],1],[["新","幹"],1],[["線",""],1],[["越","新"],1]] ・ ・
こんな感じでngramのカウントが取れる。同一の並びが複数あった場合はカウントアップされてまとめられる。下記の「ゆうゆうあぶくまライン」は「ゆう」が2回カウントされている。
11228 ゆうゆうあぶくまライン [[["ゆ","う"],2],[["","ゆ"],1],[["あ","ぶ"],1],[["う","あ"],1],[["う","ゆ"],1],[["く","ま"],1],[["ぶ","く"],1],[["ま","ラ"],1],[["イ","ン"],1],[["ラ","イ"],1],[["ン",""],1]]
今回はngramなのでsplitしてるけど、英文を区切る場合はsentenceという関数があったり、日本語の場合はhivemallにKuromojiUDF.javaが用意されていたりする。
[["", "中"],1]や[["線", ""],1]のように文字の終端との繋がりが空文字表現されているが、これはexplodeして消してみる。
消した後、元に戻しつつhivemallのfeatureに変換する。
WITH line1 AS ( -- ngramを取る SELECT line_cd, line_name, ngrams(split(line_name, ''), 2, 20) line_name_ngrams FROM test_lines GROUP BY line_cd, line_name ), line2 AS ( -- explodeで展開 SELECT line_cd, line_name, line_name_ngram FROM line1 LATERAL VIEW explode(line_name_ngrams) dummy as line_name_ngram ) -- 0文字の文字列との連結を除外し、hivemallのfeatureに変換 SELECT line_cd, line_name, COLLECT_LIST(FEATURE(CONCAT(line_name_ngram.ngram[0], line_name_ngram.ngram[1]), line_name_ngram.estfrequency)) AS line_name_ngram FROM line2 WHERE LENGTH(line_name_ngram.ngram[0]) > 0 AND LENGTH(line_name_ngram.ngram[1]) > 0 GROUP BY line_cd, line_name
これで馴染みのあるhivemall形式の一覧になった。あとはこれに対してcosine similarityすればいい。
1001 中央新幹線 ["中央:1.0","央新:1.0","幹線:1.0","新幹:1.0"] 1002 東海道新幹線 ["幹線:1.0","新幹:1.0","東海:1.0","海道:1.0","道新:1.0"] 1003 山陽新幹線 ["山陽:1.0","幹線:1.0","新幹:1.0","陽新:1.0"] 1004 東北新幹線 ["北新:1.0","幹線:1.0","新幹:1.0","東北:1.0"] 1005 上越新幹線 ["上越:1.0","幹線:1.0","新幹:1.0","越新:1.0"] ・ ・
まずはブルートフォース的に距離を測ってみる。今回はたかだか614路線、376996パターンの距離を測るだけなので、そのまま突っ込んでしまえば良い。
WITH line1 AS ( -- ngramを取る SELECT line_cd, line_name, ngrams(split(line_name, ''), 2, 20) line_name_ngrams FROM test_lines GROUP BY line_cd, line_name ), line2 AS ( -- explodeで展開 SELECT line_cd, line_name, line_name_ngram FROM line1 LATERAL VIEW explode(line_name_ngrams) dummy as line_name_ngram ), line3 AS ( -- 0文字の文字列との連結を除外し、hivemallのfeatureに変換 SELECT line_cd, line_name, COLLECT_LIST(FEATURE(CONCAT(line_name_ngram.ngram[0], line_name_ngram.ngram[1]), line_name_ngram.estfrequency)) AS line_name_ngram FROM line2 WHERE LENGTH(line_name_ngram.ngram[0]) > 0 AND LENGTH(line_name_ngram.ngram[1]) > 0 GROUP BY line_cd, line_name ), line4 AS ( -- ブルートフォースアタック! - サーバーは死ぬ SELECT l1.line_cd AS line_cd_1, l1.line_name AS line_name_1, l2.line_cd AS line_cd_2, l2.line_name AS line_name_2, cosine_similarity(l1.line_name_ngram, l2.line_name_ngram) AS similarity FROM line3 l1 JOIN line3 l2 ) SELECT * FROM line4 WHERE similarity > 0.7 AND line_cd_1 != line_cd_2 ORDER BY similarity DESC ;
ちゃんと類似の駅名が取れていることがわかる。
99808 伊予鉄道環状線 99809 伊予鉄道環状線 1 99809 伊予鉄道環状線 99808 伊予鉄道環状線 1 28006 東京メトロ有楽町線 28007 東京メトロ有楽町新線 0.8249579071998596 28007 東京メトロ有楽町新線 28006 東京メトロ有楽町線 0.8249579071998596 99332 千葉都市モノレール2号線 99331 千葉都市モノレール1号線 0.8181818127632141 99331 千葉都市モノレール1号線 99332 千葉都市モノレール2号線 0.8181818127632141 99514 名古屋市営地下鉄名城線 99515 名古屋市営地下鉄名港線 0.800000011920929 99515 名古屋市営地下鉄名港線 99514 名古屋市営地下鉄名城線 0.800000011920929 ・ ・
今回はたかだか614件のデータなのでブルートフォースでも問題ないけど、データ量が増えてくると類似度を取る必要があるパターン数が爆発的に増えるので厳しくなってくる。
その対応としてminhashでクラスタを作る方法があるらしいので使ってみる。こちらの記事で行われている方法。
WITH line1 AS ( -- ngramを取る SELECT line_cd, line_name, ngrams(split(line_name, ''), 2, 20) line_name_ngrams FROM test_lines GROUP BY line_cd, line_name ), line2 AS ( -- explodeで展開 SELECT line_cd, line_name, line_name_ngram FROM line1 LATERAL VIEW explode(line_name_ngrams) dummy as line_name_ngram ), line3 AS ( -- 0文字の文字列との連結を除外し、hivemallのfeatureに変換 SELECT line_cd, line_name, COLLECT_LIST(FEATURE(CONCAT(line_name_ngram.ngram[0], line_name_ngram.ngram[1]), line_name_ngram.estfrequency)) AS line_name_ngram FROM line2 WHERE LENGTH(line_name_ngram.ngram[0]) > 0 AND LENGTH(line_name_ngram.ngram[1]) > 0 GROUP BY line_cd, line_name ), line4 AS ( -- クラスタID取得 SELECT MINHASH(line_cd, line_name_ngram) AS (cluster_id, line_cd) FROM line3 ), line5 AS ( -- クラスタIDのJOIN SELECT line3.line_cd, line3.line_name, line3.line_name_ngram, line4.cluster_id FROM line3 JOIN line4 ON line3.line_cd = line4.line_cd ) SELECT DISTINCT l1.line_cd AS line_cd1, l1.line_name AS line_name1, l2.line_cd AS line_cd2, l2.line_name AS line_name2, COSINE_SIM(l1.line_name_ngram, l2.line_name_ngram) AS similarity FROM line5 l1 JOIN line5 l2 ON l1.cluster_id = l2.cluster_id WHERE l1.line_cd != l2.line_cd ORDER BY similarity DESC LIMIT 30
上位についてはまったく同じ結果になっている。
99808 伊予鉄道環状線 99809 伊予鉄道環状線 1.0 99809 伊予鉄道環状線 99808 伊予鉄道環状線 1.0 28007 東京メトロ有楽町新線 28006 東京メトロ有楽町線 0.8249579 28006 東京メトロ有楽町線 28007 東京メトロ有楽町新線 0.8249579 99331 千葉都市モノレール1号線 99332 千葉都市モノレール2号線 0.8181818 99332 千葉都市モノレール2号線 99331 千葉都市モノレール1号線 0.8181818 99514 名古屋市営地下鉄名城線 99515 名古屋市営地下鉄名港線 0.8 99515 名古屋市営地下鉄名港線 99514 名古屋市営地下鉄名城線 0.8 ・ ・
この処理ではそれぞれのレコードに複数のクラスタIDが振られ(なので最後にDISTINCTしないと重複したレコードが出てきたりする)、JOINをした上でsimilarityを取るので、類似度を測るよりは負荷が少ないJOINで対象を絞れる。
今回の例ではレコード数が614件。ブルートフォースでは614^2の376996回の類似度計算が行われたけど、minhashを使った方では10590回しか行われていない。およそ97.2%類似度計算回数を減らせたことになる。