論文メモ:ACL2022, Adjusting the Precision-Recall Trade-Off with Align-and-Predict Decoding for Grammatical Error Correction

Reference

@inproceedings{sun-wang-2022-adjusting,
    title = "Adjusting the Precision-Recall Trade-Off with Align-and-Predict Decoding for Grammatical Error Correction",
    author = "Sun, Xin  and
      Wang, Houfeng",
    booktitle = "Proceedings of the 60th Annual Meeting of the Association for Computational Linguistics (Volume 2: Short Papers)",
    month = may,
    year = "2022",
    address = "Dublin, Ireland",
    publisher = "Association for Computational Linguistics",
    url = "https://aclanthology.org/2022.acl-short.77",
    pages = "686--693",
}

コード

github.com

概要

seq2seqな文法誤り訂正モデルのデコード手法として,precisionとrecallを制御するAlign-and-Predict Decoding (APD)を提案.デコードの各時刻で,入力をコピーするようなトークンの生成確率のみ変更することで制御する.入力をコピーするようなトークンは,入力文と出力途中の文とのアライメントを取れば分かる(ことがある).

背景

文法誤り訂正システムを使う目的によって,precision重視にするかrecall重視にするかは変わってきます.precisoin重視であれば,ユーザに間違いのない訂正を提示できるので,ユーザのexperienceは上がります.一方で,母語話者向けのシステムであればrecall重視の方がいいかもしれません.(参考:NLP2021 高再現率な文法誤り訂正システムの実現に向けて).

近年では,文法誤り訂正モデルにはseq2seqモデルとtaggerモデルの2種類が台頭しています.taggerモデルとして代表的なGECToRは,誤り検出確率の閾値とKEEPタグのconfidenceの2つのハイパーパラメタを用いることで,precisionとrecallのどちらを重視するかをある程度制御できます.一方,seq2seqモデルでも制御する方法は提案されているものの,多くはアーキテクチャやデータなど,特定の側面に依存していることが問題です.そこで,seq2seqのデコードの方法を改良することに注目した,Align-and-Predict Decoding (APD)を提案しています.

Align-and-Predict Decoding (APD)

直感

文法誤り訂正では,入力と出力がある程度一致することが多いです.seq2seqのような自己回帰モデルにおいては,デコードの過程で入力の部分系列と同じ系列を出力する状態が頻発します.

デコードの途中の状態を考えましょう.つまり,ある時刻までの系列は出力が完了しています.また,天下り的ですが,出力文のsuffix(接尾辞)が入力文のある部分系列と一致しているとします.このとき,次の時刻でモデルが入力をコピーする場合,どのトークンを選べばいいかは入力文から知ることができます.下図は自分で作ったものですが,仮にモデルが次の時刻でも入力をコピーするなら,入力文の部分系列(赤色)の右隣のトークン(青色)を持って来ればいいと分かります.

出力文のsuffixと入力文の部分系列で一致するものがあるかは,アライメントを取れば分かります.もしあれば,入力のコピーとなるトークンも分かります.したがって,コピーとなるトークンの生成確率のみを”いじる”ことが可能です.確率が高くなるようにすると,コピーが促進されて保守的な訂正になります(high-precison, low-recall).一方,その逆では積極的な訂正になります(low-precisoin, high-recall).

論文中の具体例に移ります.Figure1のBeam1では,最新の時刻においてwordを出力しており(suffixがword),入力文にもwordが存在します.したがって,次の時刻においてweが入力のコピーとなるトークンだと分かります.一方,weの生成確率は意図的に高くも低くもできるため(適当な値と積を取ればよい)コピーを促進するかどうかを制御できます.

Figure1 文献[1]より引用.

以上の考えをビームサーチにおけるそれぞれのビームに適用します.

厳密なnotationが欲しい人は論文を読んでください.

いま,ビーム幅 Kのビームサーチをしており,時刻 tまで出力が終了しています.

あるビーム iについて,出力済みの系列のsuffix( y ^ i _ {t - j \dots t})と一致する入力文の部分系列( x _ {k - j \dots k})が見つかれば,部分系列の次のトークン( x _ {k+1})を N ^ i _ tに加えます. N ^ i _ tは入力のコピーとなるようなトークンの集合です.

一旦話は普通のビームサーチに戻ります.とりあえず次の時刻のトークンとして全ての語彙を考えます.

top-kを取るために文のスコアリングをする必要があるので,次のようにスコアを計算します.普通のビームサーチと異なるのは, w ^ i _ {t , v}の重みの項がある点です.

 w ^ i _ {t , v}は,次のように決まります. \lambdaはハイパーパラメタです. N ^ i _ tに属する語彙を出力するとコピーと同義になり,そのような語彙に対する重みが \lambdaです. \lambda > 1.0であれば積極的な訂正になり, \lambda < 1.0であれば保守的な訂正になります.(4)式は単に \log P()の形である(マイナスしてない)ため, \lambdaの大小の解釈に注意します.

最後に,次の時刻のビームは, \lambdaを考慮したスコアのtop-kを取ることで得られます.式中のarg topKは i v に関係しているので,全てのビームと全ての語彙を考慮したtop-kになっています(普通のビームサーチと同じです).

実験と結果

実験は英語と中国語で行っています.詳しい実験設定は論文を参照してください.

英語では, \lambdaの値によってprecisionとrecallの制御が実現されていることがわかります.また,BARTで重みを初期化した場合(下図の最下ブロック)において, \lambda = 0.75に設定したときの F _ {0.5}は先行研究を上回っています.

中国語での実験でも, \lambdaの値によってprecisionとrecallの制御が実現されていることがわかります. \lambda = 0.75に設定したときの F _ {0.5}は先行研究を上回っています.

分析

 \lambdaの値によって,precision,recall, F _ {0.5}がどのように変化するか調べています.データはBEA19共通タスクの開発データです.Figure2の結果は \lambdaが小さいほどhigh precision, low recallになっており,直感的です.

最後に,実例が示されています. \lambdaの値が大きくなるほど,積極的に訂正していることが分かります.

感想

(2)式で出力文のsuffixと一致する入力文の部分系列を探すパートがありますが,ここで2トークン以上の部分系列が引っかかる場合があるのか気になりました.末尾Nトークンが一致するなら当然末尾N-1トークンも一致するので,再帰的に考えると末尾の1トークンが一致する場合しかないのではないかと思いました.言い換えると,(2)式の jが1より大きくなるのか?と思いました.

デコードの時刻を進めるたびに入力文と比較するので,推論時間が長いのではと思いました.しかし,AppendixのBによると,5%長くなる程度のようで,そこまで影響ないようです.

 \lambdaを高くしてhigh precisionな出力を行った場合に,どの誤りタイプが多く訂正されるのかは気になりました.よりモデルが自信を持って推定できる誤りタイプは何か(逆に自信がない誤りタイプは何か),という分析に使えそうです.関連して,多様な訂正文の生成のためにも使えそうです.訂正文が多様になるように訓練していないモデルでも,APDを用いてデコードを工夫するだけで多様な訂正文が得られます.

関連研究

イントロでも触れましたが,Taggerモデルとして代表的なGECToRは,誤り検出の閾値とKEEPタグのconfidence(底上げする量)をハイパーパラメタとしています.これにより,コピーを促進するかどうかを制御できます.

aclanthology.org

誤り検出と誤り訂正を組み合わせる手法もあります [Chen+ 2020].はじめにスパンレベルの誤り検出を系列ラベリングとして解きます.その後,誤りがあると判断されたスパンのみに対して自己回帰モデルで訂正します.この方法では,誤り検出の確率に閾値を設定することで,コピーを促進するかどうかを制御できます(下記論文のTable 4).

aclanthology.org

論文ではreferされていませんが,[Hotate+ 2019]もprecisionとrecallの制御に言及しています.この手法では,訓練データを誤り率に応じて5段階にわけ,各段階に対応する特別なトークンを入力文にくっつけて訓練します.推論時には,所望の訂正率に応じて入力文の先頭に特別なトークンを付与して入力することで,訂正する度合いを制御できます.Table 3の結果では,特別なトークンとして誤り率が少なくなるようなものを付与したときほど,high-precisionおよびlow-recallの傾向にあることが報告されています.

aclanthology.org

GPT-2を使って文のパープレキシティを計算する

とある手法の再現実装をするために学んだので覚え書き.

transformersのGPT-2を使って文のパープレキシティ(perplexity)を計算するための実装について書きます.
フレームワークはPyTorch,python3.8.10で試しています.

インストール

# 仮想環境作るなら
# python -m venv env
# source env/bin/activate
pip install torch transformers

一文のパープレキシティを計算

トークナイズ

訓練済みモデルを使うときは,語彙を揃えるために対応するトークナイザーを使います.transformersにはGPT-2のためのトークナイザーとしてGPT2TokenizerFastがあるので,これを使うことにします.モデルのIDにはgpt2を指定します.他にも,パラメータ数がより多いgpt2-largeなどが使えます.

from transformers import GPT2TokenizerFast

model_id = 'gpt2'
tokenizer = GPT2TokenizerFast.from_pretrained(model_id)
sentence = ['This is a pen .']
inputs = tokenizer(sentence, return_tensors='pt', padding=True)
print(inputs)
# 出力:{'input_ids': tensor([[1212,  318,  257, 3112,  764]]), 'attention_mask': tensor([[1, 1, 1, 1, 1]])}

トークナイザーは文のリスト('str'オブジェクトのリスト)を入力とし,dictオブジェクトを返します(厳密には,一文ならリストにしなくてもいいです).返り値であるdictオブジェクトは2つの要素を含んでいます.一つはinput_idsで,トークンがIDに変換されたものです.もう一つはattention_maskで,バッチ化するときに使うものです.共にshapeは(バッチサイズ,系列長)です.

パープレキシティの計算

モデルにはtransformersのGPT2LMHeadModelを使います.トークナイザーと同じように,モデルのIDを指定して訓練済みのモデルをロードします.

from transformers import GPT2TokenizerFast, GPT2LMHeadModel
import torch
import math

model_id = 'gpt2'
tokenizer = GPT2TokenizerFast.from_pretrained(model_id)
model = GPT2LMHeadModel.from_pretrained(model_id)
sentence = 'This is a pen .'
inputs = tokenizer(sentence, return_tensors='pt')
with torch.no_grad():
        outputs = model(input_ids=inputs['input_ids'], labels=inputs['input_ids'])
print(torch.exp(outputs.loss)) # tensor(312.8972)

modelの返り値はCausalLMOutputWithCrossAttentionsオブジェクトです.入力のlabels=input_ids=と同じテンソルを渡すとlossが計算される仕組みになっています.lossは.lossで参照できます.これはtorch.Tensorオブジェクトなので,torch.exp() で囲むことでパープレキシティが得られます.

CausalLMOutputWithCrossAttentionsの詳細.
公式のページはここ: https://github.com/huggingface/transformers/blob/master/src/transformers/models/gpt2/modeling_gpt2.py#L1084
return CausalLMOutputWithCrossAttentions(
    loss=loss,
    logits=lm_logits,
    past_key_values=transformer_outputs.past_key_values,
    hidden_states=transformer_outputs.hidden_states,
    attentions=transformer_outputs.attentions,
    cross_attentions=transformer_outputs.cross_attentions,
)

このlossはクロスエントロピー損失で計算されます.

# 引用元:https://github.com/huggingface/transformers/blob/2c3fcc647a6d04f21668b1f5400c0fd33905bbb1/src/transformers/models/gpt2/modeling_gpt2.py#L1071
        loss = None
        if labels is not None:
            # Shift so that tokens < n predict n
            shift_logits = lm_logits[..., :-1, :].contiguous()
            shift_labels = labels[..., 1:].contiguous()
            # Flatten the tokens
            loss_fct = CrossEntropyLoss()
            loss = loss_fct(shift_logits.view(-1, shift_logits.size(-1)), shift_labels.view(-1))

lm_logitsは(バッチサイズ,系列長,語彙サイズ)のshapeで,各トークンの生成確率が保存されています.lm_logits[..., :-1, :]とすることで,系列の最後尾以外の部分を抜き出しています. 一方,labelsは(バッチサイズ,系列長)のshapeで,labels[..., 1:]とすることで系列の先頭以外の部分を抜き出します.このように1トークンずらすことで,一般的なデコーディングの流れ( t_{i-1}の推定結果を使ってt_iを推定する流れ)を再現できます.

CrossEntropyLoss()はデフォルトのオプションでreduction='mean'が指定されているので,各トークンに対する損失の平均が計算されます.

複数文のパープレキシティを一度に計算(バッチ化)

バッチ化することで,複数文のパープレキシティを一度に計算することができます.基本的には上で述べた一文のみの場合と同じですが,トークナイズにpaddingの設定をする点と,クロスエントロピーの損失を計算するパートを若干自分で書く点が異なります.

トークナイズ

文によって文長が異なるので,バッチ化するときにはpadding=Trueを指定する必要があります.それから,トークナイザーの語彙にはいわゆるpad_tokenが設定されていないので,tokenizer.pad_token = tokenizer.eos_tokenとすることで追加しておきます.他にもtokenizer.add_special_tokens({'pad_token': '[PAD]'})とする方法もありますが,こうすると語彙サイズが1つ増えることでモデル側でindex out of rangeを起こして面倒なので,eos_tokenで代用します(実際,eos_tokenで代用するプログラムが多い印象です).

attention_maskを見ると,paddingされたトークンは0,そうでないトークンは1であることが分かります.

from transformers import GPT2TokenizerFast, GPT2LMHeadModel
import torch
import math

model_id = 'gpt2'
tokenizer = GPT2TokenizerFast.from_pretrained(model_id)
tokenizer.pad_token = tokenizer.eos_token
model = GPT2LMHeadModel.from_pretrained(model_id)
sentences = ['This is a pen .',
            'This a is pen .',
            'This is a pen pen pen pen .']
inputs = tokenizer(sentences, return_tensors='pt', padding=True)
print(inputs['attention_mask'])
'''
tensor([[1, 1, 1, 1, 1, 0, 0, 0],
        [1, 1, 1, 1, 1, 0, 0, 0],
        [1, 1, 1, 1, 1, 1, 1, 1]])
'''

パープレキシティの計算

モデルへの入力は基本的に一文のときと同じですが,attention_maskも追加で渡す点が異なります.しかしながら,三文を入力したのにもかかわらず,返り値のlossは一つの値になっています.これはlossの計算に使われているtorch.nn.CrossentropyLoss()reduction=オプションが,デフォルトで'mean'になっているためです.'mean'では,文の境界にかかわらず,全てのlossが平均されて一つの値を返します.

model_id = 'gpt2'
tokenizer = GPT2TokenizerFast.from_pretrained(model_id)
tokenizer.pad_token = tokenizer.eos_token
model = GPT2LMHeadModel.from_pretrained(model_id)
sentences = ['This is a pen .', 'This a is pen .', 'This is a pen pen pen pen .']
inputs = tokenizer(sentences, return_tensors='pt', padding=True)
with torch.no_grad():
    outputs = model(inputs['input_ids'], attention_mask=inputs['attention_mask'], labels=inputs['input_ids'])
print(outputs.loss) # tensor(6.3251)

そのため,outputs.logitsオブジェクトから自分でlossの計算を書きます.具体的には,torch.nn.CrossEntropyLoss(reduction='none')とすることで各トークンの損失を(平均など取ることなく)獲得し,dim=1で(つまり各文について)合計をとります.その後,それぞれの文の系列長で割ります.文の系列長は,attention_maskdim=1で合計すると得られます(attention_maskは,pad_tokenでないトークンが1となっているため).

model_id = 'gpt2'
tokenizer = GPT2TokenizerFast.from_pretrained(model_id)
tokenizer.pad_token = tokenizer.eos_token
model = GPT2LMHeadModel.from_pretrained(model_id)
sentences = ['This is a pen .', 'This a is pen .', 'This is a pen pen pen pen .']
inputs = tokenizer(sentences, return_tensors='pt', padding=True)
with torch.no_grad():
    outputs = model(inputs['input_ids'], attention_mask=inputs['attention_mask'], labels=inputs['input_ids'])
print(outputs.logits.shape) # torch.Size([3, 8, 50257])
# 1トークンずらす
shift_logits = outputs.logits[:, :-1, :].contiguous() # 確率
shift_labels = inputs['input_ids'][:, 1:].contiguous() # 正解のトークンID
shift_mask = inputs['attention_mask'][:, 1:].contiguous() # マスク
batch_size, seq_len = shift_labels.shape
loss_fn = torch.nn.CrossEntropyLoss(reduction='none') # reduction='none'に
loss = loss_fn(shift_logits.view(-1, shift_logits.size(-1)), shift_labels.view(-1)).view(batch_size, seq_len)
print(loss.shape) # torch.Size([3, 7])
# shift_maskと積をとることで,pad_tokenに対する損失を無視する.
# shift_mask.sum(dim=1)とすることで,各文のpad_tokenを除いた系列長が得られる
loss = (loss * shift_mask).sum(dim=1) / shift_mask.sum(dim=1)
print(torch.exp(loss)) # tensor([ 312.8972, 3360.7671,  125.8699])

文法誤り訂正の可視化ツールを作ってみた

本記事は,GEC (Grammatical Error Correction) Advent Calendar 2021 の22日目の記事です.

はじめに

Gold / システム出力に含まれる訂正としてどんなものがあるか,実例をボーッと眺めたいと思う時がありませんか?今はなくてもそのうちあるかもしれません.そんなときに役立つツールを作りたいということで,作ってみたという話です.GECのアドカレが始まってから思いついて,一人ハッカソン的なノリでやってみました.今までこういうツールはないと思ってやっていますが,もし存在すればこの企画が潰れます(あれば教えてください...).

Web開発的な記事になりますが,技術的な話はほぼしません.結果として何ができたかを報告するような内容になります.

できたツールは以下に公開しています(こういう開発はほとんどしたことがないので,ありえない実装をやってるかもしれませんが,ご容赦ください...).

github.com

何ができればいいのか

誤り訂正の様子を原文と共にハイライトしたいです.M2形式は確かに素晴らしいですが,人間にとってはスパンの認識が厳しく,ボーッと眺めるには頭を使います.そこで,原文の間にいい感じに割り込むようにして訂正情報を表示したいと思いました.さらに,ハイライトする誤りを,誤りタイプや置換・挿入・削除の分類で絞り込みたいです.

何ができたか

以下のような見た目のものができました.訂正情報を赤(オレンジ?)でハイライトし,誤りタイプも併記します.また,チェックボックスによって誤りタイプの絞り込みができます.例えば,誤りタイプM:DETのみを見たい場合は,MDETのみにチェックを入れます.

f:id:gotutiyan:20211222003325g:plain

使い方

冒頭に掲載したリポジトリのREADME を見ていただければ話が早いですが,原文と訂正文のファイル,もしくはM2形式のファイルを入力として,プログラムを走らせるだけです.実行したらローカルにサーバが立つので,ブラウザで見にいけば可視化したものが閲覧できます.

python run.py --orig <orig file> --cor <corrected file>
# もしくは
python run.py --m2 <m2 file>

# 実行したら,http://127.0.0.1:5000/を見にいきましょう

原文と訂正文のファイルを入力とした場合,ERRANTによって訂正のスパンを獲得します.M2形式のファイルを入力とした場合は,訂正のスパンや誤りタイプはM2形式が示すものに従います.M2を入力とする場合は--ref_idオプションでアノテータIDも指定できます.デフォルトは0です.逆に言うと,マルチリファレンスを同時に可視化することはできません,

先に述べたように,本ツールは誤りタイプで絞り込む機能があります.誤りタイプとしては基本的にERRANTの定義を想定しており,置換・挿入・削除と,品詞が関係するもの(VERBとかNOUNとか)を別々に指定できるようにしています.そのため,チェックボックスを2つのブロックに分けています.一方で,M2形式のファイルを入力とする場合,誤りタイプの定義が異なる場合があります.例えば,CoNLL-2014のアノテーションは誤りタイプの定義が異なりますし,置換・挿入・削除は明示的に付与されません.このような場合,チェックボックスは片方のブロックのみ用いられます(ちょっと色々な理由からこうなっています.これを書きながら,別に分ける必要もない気がしてきました).

展望

今後も開発するか分かりませんが,ひとまず僕が欲しい機能は実装できたと思います.M2形式を入力に受け付けるおかげで,任意の言語を入力に受け付ける点が結構良いなと思っています(対象言語の訂正情報がM2形式に従う仕様であれば).ロクにテストをしていないせいでバグがあるかもしれないので,そこは一応今後もチェックしたいと思います.

あとできたらいいなと思うのは,2つの訂正情報について比較するようなことですね.システムAとシステムBの出力があったときに,Aはこんな訂正をたくさんしているけどBはしてないね,みたいなことが分かりやすく比較できれば,GECの分析において一役買うのではないかと思っています.同様の比較はシステム出力と参照文に対しても適用可能で,そうするとエラー分析が容易になる気がします.

おわりに

ということで,今回は訂正の可視化ツールを作ってみました.

評価手法としてではない評価手法

本記事は,GEC (Grammatical Error Correction) Advent Calendar 2021 の18日目の記事です.

はじめに

評価手法は,基本的に「評価手法を提案した」ことをメインに発表されることが多いです.一方で,それ以外の主張をメインとするときでも,評価手法(とみなせるもの)が含まれることがあります.この記事では前者を「評価手法としての評価手法」,後者のことを「評価手法としてではない評価手法」と呼び,後者にフォーカスします.こうした話は,「良い訂正文とは何か」とか,「Grammaticalとは何か」という話題について,他の研究者がどう考えているかを知ることにつながると思っています.

ひとまず概要として「評価手法としてではない評価手法」がどういうものに該当するかを書きます.その後,具体的な手法をつらつらと書きたいと思います.

概要

ひとまずどういうものが「評価指標としてではない評価手法」になりうるか,というと

  • リランキング手法
  • 強化学習のリワード計算手法
  • データクリーニング手法

あたりがあると思っています.

リランキングはGECモデルに複数の訂正文を生成させて,それらの文を別のモジュールで並び替えることです.この処理においては「別のモジュール」が複数の訂正文をそれぞれ評価し,順位をつけているとみなせます.

強化学習のリワードも評価手法として捉えられます.例えばSakaguchi+ 2016はGLEUをリワード計算手法として用いています.そういう意味では,既存の評価手法がリワード計算に用いられるケースが多い気もしますが,リワード計算手法を独自に設計した場合は「評価手法としてではない評価手法」に該当します.

データのクリーニングもある種の評価手法が使われていると思います.クリーニングすべき文を判断するときや,クリーニング後の文を採用するかどうかに関する意思決定においては,文を特定の観点で評価する必要があります.

逆に考えると,評価手法は単にベンチマーク上でシステムの順位づけをするだけではなく,上のことにも応用できると思っています.そういう意味でも,評価って面白いですよね(僕だけかも).

以下には具体的な手法を列挙しますが,基本的に上の3つの文脈に当てはまると思います.このテーマについて腰を据えてサーベイしたわけではないので雑かもしれませんが,ご容赦ください(あまり時間がなかった...).

評価手法としてではない評価手法

R2L(Right to Left)

文の末尾からの確率で評価する方法です.リランキングの文脈で使われています.GECではKiyono+ 2019で使われている印象が強いです.評価軸は文の最もらしさでしょう.おそらく初出はLiu+ 2016で,ほぼ同時にSennrich+ 2016も試していたという感じだと思います.

誤り検出器

GECの訂正結果を誤り検出器の検出結果と比べて評価する方法です.リランキングの文脈で使われています.代表的なのはYuan+ 2021です.誤り検出ラベルを定義しており,検出器と訂正器のラベルの近さで並び替えます.検出はFalse Positive(モデルが修正したけど間違い)を減らすというモチベーションで導入されることが多い印象です.つまり,評価軸は誤検出が少ないかどうかになると思います.

ちなみに,「評価手法としての評価手法」ではNapoles+ 2016が誤り検出に基づいています.

入力文と生成文を用いた文ペア分類

GECの生成文を入力文(ソース)と一緒にニューラルベースの評価器に入力し,評価する方法です.Raheja+ 2020強化学習のリワード計算の文脈で提案しています.特にこの研究では,文ペア分類器は生成文を「人が訂正したものか?Generatorが訂正したものか?」という観点で分類するように学習します.ですので,評価軸は人間らしい訂正かどうかになると思います.

LM-Critic

評価対象の文に対する近傍の文を多数生成し,評価対象の文のPPLが一番低ければOKみたいな評価方法です.Yasunaga+ 2021が,BIFIという手法をGECに適用するために提案しました.BIFIの詳細は11日目の記事で触れましたが,主にデータクリーニングの文脈で使われていると思っています.評価軸は文がGrammaticalかどうかです(著者らはGrammaticalにおける前提をけっこう強く置いていて,だからこそなせる技かもしれない).

PPLの比較(2文に対する比較)

ある2文が存在するときにPPLを比べて優劣をつけるための評価方法です.Mita+ 2020はデータのデノイズの文脈で,PPLを比べることで文の優劣を評価しています.PPLは文の尤度に絡むものなので,評価軸はR2Lと同様,文の最もらしさです.

上で述べたLM-CriticもPPLに基づきますが,入力が1文です.ここでは2文を比べて何かモノをいう場面を想定していて,項目を分けました.

「評価手法としての評価手法」では,参照なし評価手法であるScribendi Score(Islam+ 2021)がPPLの比較に基づいています.

おわりに

「評価手法としてではない評価手法」にフォーカスしました.個人的には,評価手法はベンチマークの評価以外にも応用できるということが再確認できました.また,今回紹介したように,モデルを提案する手法の中にも評価を独自の視点で組み込んでいることがあるので,今後も注目すると面白いかもしれません.

GECの論文紹介:EMNLP2021

本記事は,GEC (Grammatical Error Correction) Advent Calendar 2021 の11日目の記事です.

はじめに

国際会議EMNLP2021にフォーカスし,GEC(Grammatical Error Correction,文法誤り訂正)に関する論文で特に気になったものを3件紹介します.本当はGEC関係の論文を全て紹介するつもりでしたが,文字数がえらいことになるので3件にしました.読者の多くは既に内容を知っている気がするので,内容は簡単に済ませて,個人的な考えを多めに盛り込むような構成になっていると思います.

本記事の記述の中には厳密でない表現があるかもしれませんが,ご容赦ください.

1. Is this the end of the gold standard? A straightforward reference-less grammatical error correction metric

aclanthology.org

参照なし評価尺度を提案した論文です.システム出力文を0(訂正なし),1(改善した),-1(悪化した)の3値で評価します.出力文それぞれに対してこの評価値を計算し,合計がシステムの評価値になります.つまり,文数を Nとすると評価値は [-N, N]のレンジになります.

改善した/悪化した ことは,Listing 1に示されたプロセスで判断します.はじめに一致判定を行い,次にPPLの値を見ます.PPLの値は低いほど良いので,sourceよりpredictionのほうがPPLの値が高いとき,それは悪化したと判断します(-1が返る).最後に,token_sort_ratioとlevenshtein-distance_ratioのうち,いずれかが0.8以上であれば改善したと判断し,そうでなければ悪化したと判断します.token_sort_ratioは,2つの文に同じトークンが同じ数含まれているほど高くなるような一致度です.levenshtein_distance_ratioは, 1 - \frac{ LD }{len(source)\; +\; len(prediction)}で定義される一致度です( LDは編集距離.ただし,置換コストが2,挿入・削除のコストが1).

f:id:gotutiyan:20211202142616p:plain
[Islam+ 2021]より

結果としては,人手相関が高いこと,同じフレーズを繰り返すような文をちゃんと悪いと言えることを示しています.

個人的には,3値分類というざっくりとした基準での評価であるにも関わらず,人手相関が0.8前後あることに驚きました.人手評価に近いランキングを得ることだけを目標にするなら,案外ざっくりでも良いのかもしれません.一方で,学術的な議論をする上で,モデルが何を解けて何を解けないかを分析することを考えると,この評価指標は使いにくいと思います.

疑問点としては,流暢な訂正がちゃんと良いと言えるか?というところが気になります.token sort ratioやlevenshtein distance ratioは,表層的に近いものを評価する尺度になっていると思います.一方で,流暢な訂正はそれなりに大きな書き換えになることが言われている(JFLEGの論文をreferしておきます)と思いますが,ratioによる評価は表層的に近いものを認めるようになっているので,流暢な訂正を悪いと言ってしまいそうな気もします(maxを取る方法や0.8という閾値が絶妙で,うまくやっているのかもしれません).

あと気になったのは,人がアノテーションした正解文をpredだと思って提案評価尺度に入れると,全部1(改善した)になるのでしょうか?そのうちやってみたいと思います.

おまけ:Levenshtein distance ratioの実装

import numpy as np
def lev_dist_ratio(src: str, pred:str) -> float:
    len_src = len(src)
    len_pred = len(pred)
    dp = np.zeros((len_src+1, len_pred+1))
    for i in range(1, len_src+1):
        dp[i][0] = i
    for j in range(1, len_pred+1):
        dp[0][j] = j
    for i in range(1, len_src+1):
        for j in range(1, len_pred+1):
            cost = 0
            if src[i-1] != pred[j-1]:
                cost = 2 # 置換コストは2
            dp[i][j] = min(
                dp[i-1][j-1] + cost,
                min(dp[i-1][j] + 1, dp[i][j-1] + 1)
            )
    return 1 - dp[len_src][len_pred] / (len_src + len_pred)

2. LM-Critic: Language Models for Unsupervised Grammatical Error Correction

aclanthology.org

GECの教師なしアプローチを提案しています.ソースコード修復タスクのために提案されたBIFIという手法をGECに適用しました.また,BIFIは文が正しいかどうか見極めるCriticの機構を必要とするので,GECのためのCriticであるLM-Criticも提案しています.

GECにおけるBIFIは,誤りを生成するbreakerと,誤りを訂正するfixerが相互に働いて学習を進めます.最終的にはfixerをGECモデルとして使うことになります.BIFIは,以下の(8)から(11)式を1サイクルとし,このサイクルを回すことで学習を進めます.つまり,breakerが作った誤りデータを元にfixerが学習し,fixerが作った正しいデータでbreakerが学習し,breakerが作った誤りデータでfixerが学習し・・・というプロセスを繰り返すことになります.いわゆるGAN的な構造だとは思いますが,お互いにデータを作るだけでパラメータの更新には直接関与しない点がちょっと違うと思います.

f:id:gotutiyan:20211202143026p:plain
[Yasunaga+ 2021]より

それから,breakerが生成した誤り文・fixerが訂正した文が,本当に誤っているのか ・正しいのかを判定するLM-Criticも提案しています.上の式では赤文字で表されており,xを文として,c(x) = 1(正しい文である)or 0(誤り文である)を返します.手法としては,文字レベルや単語レベルの編集を行い,xの近傍の文を数百文生成します.それから,近傍の文,それから入力であるxのPPLをそれぞれ計算し,xのPPLが最も低ければ1(正しい),そうでなければ0(誤り)として判断します.つまり,xが正しい(grammatical)であると結論づけるためには,全ての近傍の文にPPLで勝つ必要があります.

結果,CoNLL2014のF0.5で55.5を達成しました.教師なしではだいぶ高い性能だと思います.教師ありの設定では,F0.5が65.8とGECToRを少し上回るぐらいの性能を示したようです.

これを研究室の論文紹介で話したとき,「正解と誤りのペアを作っているなら教師ありなのでは」という意見があって,確かに教師なしの定義とはなんだろうと思ってしまいました.この論文では「人が作った正解データを使うかどうか」が教師あり/なしの基準になっています.従来のLMで頑張るような手法(Bryant+ 2018, Alikaniotis+ 2019など)と比べると,(擬似データのみとはいえ)誤り文と正しい文のペアを与えている時点で多少有利な設定なのかなと思いました.それから,BIFIはbreakerとfixerが交互に高め合えることが利点ですが,論文ではこれを1ラウンドしか回していません.おそらく(8)から(11)式をひと舐めして終わりだと思います.せっかくサイクルを回せるような手法になっているので,もっと回したら良いのにと思いました.回すとむしろ悪化したのかもしれません...?

一方で,近傍の文とPPLを比べるLM-Criticの発想はとても興味深いものでした.近傍の作り方は色々考えられるので,工夫すればもっと良い指標になるかもしれません.また,今回はPPLが最も低い文(だけ)がGrammaicalだ,というハードなCritic設計になっていますが,ソフトな基準にすれば使いどころが増えるのかなと思いました.例えば,PPLの低さで10位以内に入っていればOKだとか,PPL低さの順位を連続値に変換して扱うとかは考えられます.BIFIに組み込むことを考えるとハードな設計であるべきですけども.

また,この研究でのBIFIはほぼ擬似誤り生成手法と見て良いと思っています.今までの擬似データ生成手法と異なるのは,生成した擬似誤り文が本当に誤りなのかを,LM-Criticでちゃんと判断しているところです.擬似誤り生成はよく取り組まれていますが,生成した擬似誤りは全部使うのが普通だと思います.一方,BIFIではその質をちゃんとLM-Criticで判断して,質の悪い擬似誤りは捨てます.この点はけっこう重要な視点かもしれないと感じました(先行研究でこういうのはなかった気がするのですが,ありましたっけ?質の悪いデータを改善するという意味ではMita+ 2020は近そうです).

3. Multi-Class Grammatical Error Detection for Correction: A Tale of Two Systems

GECにGED(Detection)を本気で取り入れたような論文です.ICLR2020に採択されたELECTRAをベースにしたGEDシステムを提案し,Transformerに追加でEncodeしたり,リランキングに使ったりします.GEDはトークン単位でmulti-class({2, 4, 25, 55}-class)な分類をします.例えば,2-classは正解/誤りの2値分類ですが,4-classだと誤りラベルが置換/挿入/削除まで細分化します(本記事では載せませんが,詳細はTable 1).

GEDの情報は,Transformerのアーキテクチャを拡張したMulti-Encoderに使われます(Figure 1).

f:id:gotutiyan:20211211010140p:plain
[Yuan+ 2021]より

また,リランキングにも使われます.具体的には,GEDシステムが推定した検出ラベルを正解とみなし,それと最も一致するようなGECシステムの出力を選びます(GECシステムの出力は検出ラベルではなく文ですので,入力文とのアライメントを取って検出ラベルに変換します).一致度にはハミング距離を使っているようです.

個人的には,検出の情報を明示的に与えることにどれくらい意味があるのかなと思っていました.検出できないと訂正できないので,GECシステムも既に検出の情報を考慮しているはずです.このことから,それに加えてさらに検出の情報を与えても効果は薄いのではと思っていました.論文ではこの辺りちゃんと実験がされていて,「検出情報のオラクルを用いたときに訂正がどれくらいできるか」が調べられています(ここでは載せませんが,Table 2).この実験の結果は,検出の情報がちゃんと取れているとすると訂正の精度も飛躍的に上がるということが示唆されています.検出の情報を明示的に与えることには案外意味がありそうだという気持ちになりました.

それから,現状のコミュニティでは,GECはみんなやっていますが,GEDを極めるような研究はあまり無いように思います.すぐに思いつくのはNagata+ 2021Kaneko+ 2019あたりでしょうか.でもGECに比べると圧倒的に少ない印象です.タスク設定としてはGECよりもGEDの方が明らかに解きやすいので,もっと取り組まれても良いような気はしました.GEDを極めて高い精度で検出できるようになれば,GECも大きく進化するかもしれません.

この3本を読んで

今回は3本を紹介しました.そのうち2本はモデル提案系の話ですが,「良い訂正文とは何か」をちゃんと評価する機構を採用しているのが印象的でした.2本目で紹介した論文ではLM-Criticが評価方法に対応していて,他のことにも使えそうな気がします.3本目で紹介した論文ではリランキングの方法を提案していました.リランキングはGECモデルの確率とは別の評価尺度で再評価していると捉えられます.ですので,リランキング手法は実質参照なし評価手法ぐらいに思っていて,注目してきたいと思っています.

Reference

Islam, Md Asadul, and Enrico Magnani. "Is this the end of the gold standard? A straightforward reference-less grammatical error correction metric." Proceedings of the 2021 Conference on Empirical Methods in Natural Language Processing. 2021

Yasunaga, Michihiro, Jure Leskovec, and Percy Liang. "LM-Critic: Language Models for Unsupervised Grammatical Error Correction." Proceedings of the 2021 Conference on Empirical Methods in Natural Language Processing. 2021.

Yuan, Zheng, et al. "Multi-Class Grammatical Error Detection for Correction: A Tale of Two Systems." Proceedings of the 2021 Conference on Empirical Methods in Natural Language Processing. 2021.

Gramformerを動かしてみた

本記事は,GEC (Grammatical Error Correction) Advent Calendar 2021 の9日目の記事です.

はじめに

2021/11月末にGramformerというリポジトリを見つけました.

github.com

特に何かの論文の実装というわけではなさそうで,プロジェクトを立ち上げたという感じの雰囲気です.出来たてのプロジェクトですが(first commitは今年7月),既にStarが800以上付いており,注目されていることが伺えます.まだまだ未実装の部分も多く未知数ですが,面白そうな取り組みなので速報的に紹介します.

何に使えるか

READMEによると,Gramformerは次のようなケースで使えるとしています.

  1. Post-processing machine generated text

  2. Human-In-The-Loop (HITL) text

  3. Assisted writing for humans

  4. Custom Platform integration

(今から書くのは僕の妄想ですが)これらを見る限り,単にGECシステムを扱うリポジトリというわけではなく,もっと広いところを見ているように感じます.1.からはGECに止まらないタスクとの関わりを予感します.また,2.からは誤りのない正しい文とか,システム出力をpost-processing的に人手修正したようなデータが蓄積されそうな気がしています.最後に4.はよりアプリケーションを意識した記述です.おそらくgrammarlyなどの既に知られた訂正ツールは企業運営のものばかりなので,オープンソースなものが構築されることに意義を主張しているのだと思います.3.は現在のGECコミュニティが最も強く意識している項目だと思います.GECに関する大きなプロジェクトになりそうなので,楽しみですね.

何ができるか

現状公開されている範囲では,Correcter(訂正器),Detector(検出器),Get Edits),Highlighterの機能を提供するようです.

Install

pythonは3.7推奨のようです.

pip3 install pip==20.1.1 
# IMPORTANT NOTE: (If install runs endlessly resolving package versions in for instance colab, refer to issue #22 - https://github.com/PrithivirajDamodaran/Gramformer/issues/22)
pip3 install -U git+https://github.com/PrithivirajDamodaran/Gramformer.git

Correcter

文中の誤りを訂正します.Gramformerオブジェクトの.correct()を呼ぶだけなので,簡単です.

from gramformer import Gramformer
import torch

def set_seed(seed):
  torch.manual_seed(seed)
  if torch.cuda.is_available():
    torch.cuda.manual_seed_all(seed)

set_seed(1212)

gf = Gramformer(models = 1, use_gpu=False) # 1=corrector, 2=detector

influent_sentences = [
    "He are moving here.",
    "I am doing fine. How is you?"
]   

for influent_sentence in influent_sentences:
    corrected_sentences = gf.correct(influent_sentence, max_candidates=1)
    print("[Input] ", influent_sentence)
    for corrected_sentence in corrected_sentences:
      print("[Correction] ",corrected_sentence)
    print("-" *100)

'''
出力:
[Gramformer] Grammar error correct/highlight model loaded..
[Input]  He are moving here.
[Correction]  ('He is moving here.', -31.02850341796875)
----------------------------------------------------------------------------------------------------
[Input]  I am doing fine. How is you?
[Correction]  ('I am doing fine, how are you?', -37.6710205078125)
----------------------------------------------------------------------------------------------------
'''

Get Edits

ERRANTのアライメント手法を用いてアライメントを取ります.Gramformerオブジェクトの.get_edits()を呼ぶだけです.ERRNATは2019年12月ごろにpythonモジュールとしてimportできるようになっているので,それを活用した形になります.

... <前略> ...
for influent_sentence in influent_sentences:
    corrected_sentences = gf.correct(influent_sentence, max_candidates=1)
    print("[Input] ", influent_sentence)
    for corrected_sentence in corrected_sentences:
      print("[Edits] ", gf.get_edits(influent_sentence, corrected_sentence[0]))
    print("-" *100)

'''
出力:
[Gramformer] Grammar error correct/highlight model loaded..
[Input]  He are moving here.
[Edits]  [('VERB:SVA', 'are', 1, 2, 'is', 1, 2)]
----------------------------------------------------------------------------------------------------
[Input]  I am doing fine. How is you?
[Edits]  [('OTHER', 'fine.', 3, 4, 'fine,', 3, 4), ('ORTH', 'How', 4, 5, 'how', 4, 5), ('VERB:SVA', 'is', 5, 6, 'are', 5, 6)]
----------------------------------------------------------------------------------------------------
'''

Highlighter

ソースに訂正スパンを埋め込む形で出力します.Gramformerオブジェクトの.highlight()を呼ぶことで使えます.

... <前略> ...
for influent_sentence in influent_sentences:
    corrected_sentences = gf.correct(influent_sentence, max_candidates=1)
    print("[Input] ", influent_sentence)
    for corrected_sentence in corrected_sentences:
      print("[Edits] ", gf.highlight(influent_sentence, corrected_sentence[0]))
    print("-" *100)
'''
出力:
[Gramformer] Grammar error correct/highlight model loaded..
[Input]  He are moving here.
[Edits]  He <c type='VERB:SVA' edit='is'>are</c> moving here.
----------------------------------------------------------------------------------------------------
[Input]  I am doing fine. How is you?
[Edits]  I am doing <c type='OTHER' edit='fine,'>fine.</c> <c type='ORTH' edit='how'>How</c> <c type='VERB:SVA' edit='are'>is</c> you?
----------------------------------------------------------------------------------------------------
'''

Detector

検出器は現在(2021/12/8)未実装ですが,Gramformerオブジェクトの.detect()で利用できることが示されています.

モデル

モデルはgrammar_error_correcter_v1と呼ばれるSeq2Seqなモデルを使っているようです.アーキテクチャはよくわかりません.訓練データはWikiEdits,C4ベースの擬似誤りデータ(Stahlberg+ 2020),PIEの擬似誤りデータ(Awasthi+ 2019)を使っているようです.推論時には,GPT-2のスコアによるリランキングをしています.

性能をざっくり検証

せっかくなのでCoNLL-2014 test setの性能を見てみたいと思います.Gramformerがトップで出力した文をそのまま評価に使います.

  • コード(p.pyとします)
from gramformer import Gramformer
import torch
import argparse

def set_seed(seed):
  torch.manual_seed(seed)
  if torch.cuda.is_available():
    torch.cuda.manual_seed_all(seed)

gf = Gramformer(models = 1, use_gpu=False) # 1=corrector, 2=detector

def main(args):
    set_seed(1212)
    gf = Gramformer(models = 1, use_gpu=False)
    outputs = []
    with open(args.in_file) as fp:
        for src in fp:
            src = src.rstrip()
            corrected_sentences = gf.correct(src, max_candidates=1)
            outputs.append(corrected_sentences[0][0])
    with open(args.out_file, "w") as fp:
        fp.writelines(outputs)
    

def get_parser():
    parser = argparse.ArgumentParser()
    parser.add_argument('--in_file', required=True)
    parser.add_argument('--out_file', required=True)
    args = parser.parse_args()
    return args

if __name__ == '__main__':
    args = get_parser()
    main(args)


* コマンドたち

# データの準備
wget https://www.comp.nus.edu.sg/~nlp/conll14st/conll14st-test-data.tar.gz
tar -xf conll14st-test-data.tar.gz
cat conll14st-test-data/noalt/official-2014.combined.m2 | grep '^S ' | cut -d ' ' -f 2- > conll14st-test-data/noalt/orig.txt
# 実行
python p.py --in_file conll14st-test-data/noalt/orig.txt --out_file conll14_out.txt
# 評価
wget https://www.comp.nus.edu.sg/~nlp/sw/m2scorer.tar.gz
tar -xf m2scorer.tar.gz
cd m2scorer
./m2scorer ../conll14_out.txt ../conll14st-test-data/noalt/official-2014.combined.m2


* 結果

Precision   : 0.1873
Recall      : 0.2637
F_0.5       : 0.1988

変に低いですね.出力を眺めると,Detokenizeまでされているので,単語のインデックスが合わなくなっているようです(CoNLL-2014の評価データのソースはtokenizeされている).そこで,Post-processingとしてspacyでtokenizeしました(これはちょっと適当です.ゆるして).その後もう一度評価すると

Precision   : 0.4565
Recall      : 0.2778
F_0.5       : 0.4045

ということで,RNNのベースラインくらいの性能でしょうか(よりも低い?).ちょっと雑なので正確な性能ではないかもしれませんが,今後のモデルの追加に期待です.

おわり

Gramformerが面白そうですねという記事でした.今の感じだと結構簡単に使えそうなので,この先どれくらい強いモデルが入るか?というところに期待です.この記事の情報はすぐに古くなると思いますが,使用感が伝わればと思います.

文法誤り訂正タスクの情報源

本記事は,GEC (Grammatical Error Correction) Advent Calendar 2021 の7日目の記事です.

はじめに

文法誤り訂正タスク(GEC, Grammatical Error Correction)は翻訳や情報抽出などのタスクに比べるとマイナータスクですが,最近は研究する人が増えている印象です.これに伴って,情報収集に役立つようなページの需要も高まっていると思いますので,いくつか紹介したいと思います.その後,個人的にこういう情報がまとまっていたら嬉しいなという気持ちを書きます.

既存の情報源

web上では,次のような資料が情報源として有用です.

NLP-progress

nlpprogress.com

言語処理関係のタスクにおいてSoTAをまとめたリポジトリです.GECも含まれており,現状の性能が高いモデルを知ることができます.論文とコードのリンクが貼られているのもありがたいです.一方で,性能ベースで文献が追加されるため網羅性は低いです.基本的にSoTAな論文でないとマージされにくい印象があります.しかしながら,ターニングポイントというか,「この手法により性能が上がった」という流れを追うには十分だと思います.

サーベイ論文

arxiv.org

GECはサーベイ論文がほとんど無い印象なのですが,2020年にサーベイ論文が出ています.2019年くらいまでのデータセットやGECモデル,評価指標について重要な研究がまとまっている印象です(まだ全部読んで無いんですけど...).定期的にサーベイ論文が出ると助かりますね.

私のブックマーク「自然言語処理による文法誤り訂正」

www.ai-gakkai.or.jp

2018年までの話題について,重要な文献がまとまっています.日本語で書かれたGECのまとめ記事はこれしか無いのでは?というくらい貴重な記事だと思っています.pdf版とweb版があって,web版のほうが直接各種リンクに飛べるので便利です.

A Crash Course in Automatic Grammatical Error Correction

github.com

国際会議COLING2020におけるGECのチュートリアルです.GECのタスク説明から始まり,ルールベースの手法→SMT→DNNといった流れを追うことができます.個人的にはニューラル以降から参入した身なので,ルールベースのような古典的なアプローチを知ることも重要だよなと思うなどしました(古典的と表現していいのか分かりませんが).

著者もRoman Grundkiewicz, Christopher Bryant, Mariano Feliceと豪華です.例えば,Christopher Bryant氏とMariano Felice氏はERRANT([Felice+ 2016], [Bryant+ 2017])の著者です.また,Roman Grundkiewicz氏は多数のモデル提案系の論文に関わっていますし,システムの人手評価の研究([Grundkiewicz+ 2015])でも有名だと思います.

Chunngai/gec-papers

github.com

2019-2020あたりの期間について,GECの論文がまとまっています.論文の簡単な要約も書かれています.

GEC-Info(ステマ

github.com

主にニューラル以降の手法について,論文やツールがまとまっています.性能は一切関係なく,手法別で分類するようなポリシーでまとめられています.論文が中心ですが,関連するツールや資料なども一部掲載されています.今後もジャンジャン追加予定です.分類形態も模索中です.

今後の展望(お気持ち)

実装

実装はどうしても分散するので,まとまった情報源というものはないですかね....翻訳でいうfairseqのようにGECの実装も同じフレームワークの元でまとまってくれたらなあとはよく思います.フレームワークが分散していると,何かを拡張したいときにそのフレームワークをそれぞれ学ぶ必要があるので,個人的には大変に感じます.例えば,コピー機[Zhao+ 2019]公式実装は2年前のfairseqを直置き&書き換えて実装しているので,これをベースに拡張するには2年前のfairseqのアレコレを学ぶ必要があります.他方でGECToR [Omelianchuk + 2019]公式実装に目をやるとAllenNLPを使っていて,やーなかなか大変だなと思うなどします.もちろんコードを整備して公開してくれているだけで神なんですが,人は欲張りなのでこういうことを思ってしまいます(僕だけなのかもしれない).実装力を鍛えねば....

何か統一的なもの,できないですかねえ 壁|▱°)

システム出力

他の要素としては,システム出力がまとまった場所があると嬉しいなと思います.気軽に色々な手法の出力を引っ張ってこれるようになると,分析が簡単に始められます.個人的には,難易度を考慮した評価尺度([Gotou+ 2020])を研究したとき,その手法からたくさんのシステム出力を必要としたこともあります.(ところで,たまにCoNLL-2014のシステム出力がGitHubに上がっているのを見ますが,データのライセンス的には大丈夫なんでしょうか?見方によっては改変しての再配布になりそうですが...)

おわり

情報がまとまっていれば嬉しいよね〜〜という話を書いてみました.