データナード

機械学習と自然言語処理についての備忘録 (旧ナード戦隊データマン)

パラフレーズ獲得のbilingual pivotingを実装

bilingual pivotingは、対訳コーパスからパラフレーズを獲得する方法です。

github.com

概要

ググったらMIPA ( https://www.aclweb.org/anthology/I17-1009/ ) という手法が出てきたので、それを参考に言い換えの獲得をします。

基本的には、日英コーパスを使い、英語を中間言語として  P(e2|e1) = \Sigma_f P(e2|f)P(f|e1) を計算します。ただし、MIPAはそれを更に改善した手法なので、MIPAを実装します。詳細はMIPAの論文を見てください (私はただのプログラマーであり、論文の著者とは一切無関係です)。

事前準備

mosesで対訳フレーズのアラインメント

mkdir ~/smt/corpus
cd ~/smt/corpus
wget ftp://ftp.monash.edu.au/pub/nihongo/examples.utf.gz
gzip -dc examples.utf.gz | grep ^A: | cut -f1 | sed 's/^A: //' | mecab -Owakati >tanaka.ja
gzip -dc examples.utf.gz | grep ^A: | cut -f2 | sed 's/#.*$//' >tanaka.en
../mosesdecoder/bin/lmplz -o 5 -S 80% -T /tmp <tanaka.ja >tanaka.ja.arpa
cd ../
./mosesdecoder/scripts/training/train-model.perl --root-dir .  --corpus corpus/tanaka  --f en  --e ja  --lm 0:5:$HOME/work/smt/corpus/tanaka.ja.arpa --external-bin-dir=./mosesdecoder/bin --mgiza

のように実行すると、phrase-table.gzというデータが生成されますが、phrase-table.gzの内容の意味がよくわからんので、途中で生成されたっぽいextract.sorted.gzとextract.inv.sorted.gz (対応するフレーズが抽出されたようなデータ) を使います。

ただし、examples.utf.gzはtanakaコーパスと呼ばれるものです。

word2vecとkenlm

gensimで日本語word2vecを作成し、さらにkenlmで日本語の言語モデルを作成しておいてください。この日本語のkenlmはmosesで使ったtanaka.ja.arpaを使いました。

コード

import math
import json
import jaconv
import numpy as np
import kenlm
from tqdm import tqdm
from gensim.models import KeyedVectors

kv = KeyedVectors.load("./word2vec.model", mmap="r")
klm = kenlm.Model("./tanaka.ja.bin")


def biprob(dic_ef, dic_fe):
    '''
    caclucate bilingual pivoting as classic algorithm.

    dic_ef: probabilistic dictionary. dict[e1][f] = P(f|e1)
    dic_fe: probabilistic dictionary. dict[f][e2] = P(e2|f)

    return dict[e1][e2] = P(e2|e1)
    '''

    out = {}
    for e1, fs in tqdm(dic_ef.items()):
        if e1 not in out:
            out[e1] = {}
        for f, p1 in list(fs.items()):
            e2s = dic_fe[f]
            for e2, p2 in list(e2s.items()):
                if e2 not in out[e1]:
                    out[e1][e2] = 0.0
                out[e1][e2] += p1 * p2
    return out


def localPMI_scoring(infile="./pdic_kn.json"):
    '''
    calculate local PMI.
    return dict[e1][e2] = cos(e1, e2)2PMI(e1, e2)
    '''

    out = {}
    with open(infile) as f:
        pdic = json.load(f)
    for e1, e2d in tqdm(pdic.items()):
        for e2, _ in e2d.items():
            try:
                score = s_lpmi(e1, e2, pdic, klm, kv)
            except Exception:
                continue
            if e1 not in out:
                out[e1] = {}
            if e2 not in out[e1]:
                out[e1][e2] = score
            else:
                raise Exception("wrong")
    return out


def s_lpmi(e1, e2, pdic, klm, kv):
    v1 = np.mean([kv[w] for w in e1.split()], axis=0)
    v2 = np.mean([kv[w] for w in e2.split()], axis=0)
    cos = _cossim(v1, v2)
    p1 = 10**klm.score(e1)
    p2 = 10**klm.score(e2)
    return cos * (math.log(pdic[e1][e2]) + math.log(pdic[e2][e1]) -
                  math.log(p1) - math.log(p2))


def biprob_kn(cdic_ef, cdic_fe):
    '''
    calculate bilingual pivoting with KN smoothing.

    cdic_ef: count dict. dict[e1][f] = n(f|e1)
    cdic_fe: count dict. dict[f][e2] = n(e2|f)

    return dict[e1][e2] = P_kn(e2|e1)
    '''

    out = {}
    n_e2_e1 = {}
    n_e1 = {}
    N_all = 0.0
    N_e = {}
    N_1 = 0.0
    N_2 = 0.0

    for e1, fs in tqdm(cdic_ef.items()):
        if e1 not in n_e2_e1:
            n_e2_e1[e1] = {}
        if e1 not in n_e1:
            n_e1[e1] = 0.0
        for f, c1 in list(fs.items()):
            e2s = cdic_fe[f]
            for e2, c2 in list(e2s.items()):
                if e2 not in n_e2_e1[e1]:
                    n_e2_e1[e1][e2] = 0.0
                n_e2_e1[e1][e2] += c2
                n_e1[e1] += c2

    for e, e2d in tqdm(n_e2_e1.items()):
        N_e[e] = float(len(n_e2_e1[e].keys()))
        N_all += N_e[e]
        for e2, v in e2d.items():
            if v == 1:
                N_1 += 1.0
            elif v == 2:
                N_2 += 1.0

    p_kn = {e: v / N_all for e, v in N_e.items()}
    delta = N_1 / (N_1 + 2 * N_2)
    gamma = {e: (delta / v) * N_e[e] for e, v in n_e1.items()}

    for e1, e2d in tqdm(n_e2_e1.items()):
        if e1 not in out:
            out[e1] = {}
        for e2, c in e2d.items():
            try:
                a = (float(c) - delta) / float(n_e1[e1])
                b = gamma[e1] * p_kn[e2]
                out[e1][e2] = a + b
            except KeyError:
                continue
    return out


def _cossim(v1, v2):
    return np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2))


def create_dic(infile="../extract.sorted"):
    '''
    create phrase-pair count and prob dicts

    infile: phrase alignment data that created by moses.
    return count_dict, prob_dict
    '''

    out1 = {}
    out2 = {}
    with open(infile) as f:
        for line in tqdm(f):
            line = [
                jaconv.normalize(str(x).strip().lower(), "NFKC")
                for x in line.strip().split("|||")
            ]
            if len(line) < 2:
                continue
            if not line[0] or not line[1]:
                continue
            if line[0] not in out1:
                out1[line[0]] = {}
            if line[1] not in out1[line[0]]:
                out1[line[0]][line[1]] = 0
            out1[line[0]][line[1]] += 1
    for k1, d in tqdm(out1.items()):
        total = sum(v for k2, v in d.items())
        if k1 not in out2:
            out2[k1] = {}
        for k2, v in d.items():
            out2[k1][k2] = float(d[k2]) / float(total)
    return out1, out2


def create_dic_example():
    cdic, pdic = create_dic(infile="../extract.inv.sorted")
    with open("./example/e2f_cnt.json", "w") as f:
        json.dump(cdic, f, indent=4, ensure_ascii=False)

    with open("./example/e2f_prob.json", "w") as f:
        json.dump(pdic, f, indent=4, ensure_ascii=False)

    cdic, pdic = create_dic(infile="../extract.sorted")
    with open("./example/f2e_cnt.json", "w") as f:
        json.dump(cdic, f, indent=4, ensure_ascii=False)

    with open("./example/f2e_prob.json", "w") as f:
        json.dump(pdic, f, indent=4, ensure_ascii=False)

    with open("./example/e2f_cnt.json") as f:
        dic_ef = json.load(f)
    with open("./example/f2e_cnt.json") as f:
        dic_fe = json.load(f)

    pdic_kn = biprob_kn(dic_ef, dic_fe)
    with open("./pdic_kn.json", "w") as f:
        json.dump(pdic_kn, f, indent=2, ensure_ascii=False)

    out = localPMI_scoring("./pdic_kn.json")
    with open("./lpmi_dic_kn.json", "w") as f:
        json.dump(out, f, indent=2, ensure_ascii=False)


if __name__ == "__main__":
    create_dic_example()

    #with open("./example/e2f_prob.json") as f:
    #    dic_ef = json.load(f)
    #with open("./example/f2e_prob.json") as f:
    #    dic_fe = json.load(f)
    #pdic = biprob(dic_ef, dic_fe)
    #with open("./pdic.json", "w") as f:
    #    json.dump(pdic, f, indent=2, ensure_ascii=False)

出力の一部

出力は以下で公開しています。(ただし、threshold=35でフィルタリングして、zstd圧縮している) https://github.com/sugiyamath/bipivot/blob/master/example_output/lpmi_dic_kn.json.fil.zst

出力の一部は以下です。

{
...

  "あと に し た": {
    "あと に し た": 49.08104161075641,
    "し た に": 43.783070823735756,
    "し て い た": 36.63063091434499,
    "し まし た": 36.53356919810071,
    "た まま に し て おい た": 43.707994179644416,
    "っぱなし に し た": 45.429875241207874,
    "て おい た に": 39.214785318148294,
    "て き て しまっ た": 35.712313981332684,
    "て 出発 し た": 43.42209081295612,
    "に し た": 43.624964114650155,
    "に し て おい た": 44.49170738795261,
    "に 出発 し た": 42.39612577715014,
    "を 出 て いっ た": 36.518405543696254,
    "を 出 て 行っ た": 35.06063914593699,
    "を 出発 し た": 38.846282155856244,
    "を 卒業 し まし た": 39.70750393772064,
    "を 忘れ て き た": 35.327600796058306,
    "を 退学 し た": 41.333487813188576,
    "を 離れ て いっ た": 37.57726738112819,
    "出 て いっ た": 35.14926621454554,
    "出発 し た": 39.32325867360446,
    "出発 し て い た": 40.12085340276874,
    "出発 し て しまっ て": 35.440382963380074,
    "出発 し まし た": 40.207931123463524,
    "置き忘れ て き た": 35.46009355607956
  },
  "あと は 君 次第 だ": {
    "あと は 君 次第 だ": 58.442285275119204,
    "あと は 君 次第 だ 。": 42.621301310560526
  },
  "あと は 君 次第 だ 。": {
    "あと は 君 次第 だ": 42.621301310560526
  },
  "あと は 学生": {
    "あと は 学生": 53.50754589919626
  },

...
}

追記

2020-03-04 15:05

_gammaの部分に誤りがあったので修正 (他にも誤りがあるかもしれない。)

2020-03-05 9:34

kn smoothingの実装がおかしい部分があったため、リファクタリングしました。

2020-03-08 22:53

「誤り」「おかしい部分」とは私の実装のことです。githubで見たところ、少なくともtmu-nlp/pmi-ppdbでは、論文の著者は実装を公開していないようです。(ハイコンテクスト文化では誤解は頻繁に発生するので、誤解が発生しないような努力を忘れないようにしたいものです。この記事のpythonコードは私が一から書いたものなので、私自身のコードを私自身がコピーした罪で起訴したりしないでください。)

参考

  1. GitHub - tmu-nlp/pmi-ppdb: MIPA: Mutual Information Based Paraphrase Acquisition via Bilingual Pivoting
  2. MIPA: Mutual Information Based Paraphrase Acquisition via Bilingual Pivoting - ACL Anthology
  3. Mosesの使い方 - Qiita