BertTokenizerなどでtokenizeした文をきれいに元に戻したい(Detokenization)

# 前提
HuggingfaceにあるBertTokenizer等でテキストをtokenizeしたあと，元と全く同じ形に戻す．
対象は英語

# はじめに
自然言語処理といえばBERT，と言ってもいいほどBERT，その他Transformer系がつよつよの時代ですが，そんなBERTを使う際には，まずテキストをtokenizeする必要がありますね．おそらくHuggingfaceに実装されているBertTokenizerを使う方が多いのではないでしょうか．

質問応答などでモデルの出力した解答スパンのindexを基に解答文字列を取得する際，色々とめんどくさいことがあります．それらの問題を解決するためのTipsをここでは紹介します．

# QA

質問応答等のタスクにおいて，BERT等大半のQAアーキテクチャーは解答を得るためにはモデル出力されたindexを基に文字列に変換する必要があります．indexについてですが，BertForQuestionAnsweringを含め，一般的にBERTを使ったQAは質問とを文書を入力として，文書における解答スパンの開始位置と終了位置のスコアを各indexごとに出力します．それらの積を取って最大になるようなスパンが，モデルの予測した解答となるわけです．

そのindexはそのままではただのint型の数字です．我々が欲しいのは解答の文字列なので，変換してあげる必要があります．ここで思いつくのは，おそらくTokenizerを使うことです．

実際の動きを見てみましょう．
まず，質問と文書を用意して，tokenizeします．

```python
Q = "What is the length of the track where the 2013 Liqui Moly Bathurst 12 Hour was staged??" 
A = "6.213 km long"
D = "The 6.213 km long track is technically a street circuit, and is a public road, with normal speed restrictions, when no racing events are being run, and there are many residences which can only be accessed from the circuit."


autotokenizer = AutoTokenizer.from_pretrained('bert-large-cased')
encoded = autotokenizer(Q, D)
encoded
>>> {'input_ids': [101, 1327, 1110, 1103, 2251, 1104, 1103, 1854, 1187, 1103, 1381, 5255, 18276, 12556, 1193, 24231, 1367, 12197, 1108, 9645, 136, 136, 102, 1109, 127, 119, 21640, 1557, 1263, 1854, 1110, 12444, 170, 2472, 6090, 117, 1105, 1110, 170, 1470, 1812, 117, 1114, 2999, 2420, 9118, 117, 1165, 1185, 3915, 1958, 1132, 1217, 1576, 117, 1105, 1175, 1132, 1242, 15417, 1134, 1169, 1178, 1129, 12269, 1121, 1103, 6090, 119, 102], 
'token_type_ids': [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], 
'attention_mask': [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]}
```
まあこんな感じになりますよね．
では，解答スパンのindexをモデルがうまく予測できたとして，そこから解答文字列を得てみようと思います．huggingfaceの場合，.decode()で，input_idsからstringに戻すことができます．skip_special_tokens=Trueを指定すると[SEP]等のspecial_tokensを無視します．
解答は"6.213 km long"なので，input_idsにおける解答開始位置は24，終了位置は28+1となります．

```python
pred = autotokenizer.decode(encoded['input_ids'][24:29], skip_special_tokens=True)
pred
>>> '6. 213 km long'
```
はい，思った通りの結果が得られました．それでは，これで終わりにしようと思います．

...とはなりません．
なんか文字列の形違いますね．

```python
A.split()
>>> ['6.213', 'km', 'long']

pred.split()
>>> ['6.', '213', 'km', 'long']
```

'6.213'の部分がdetokenizeした方はピリオドを境に分離してしまっています．

実はhuggingface以外のtokenizerの一部においてもそうですが，tokenizeの際，一部の情報は失われ，detokenize(decode)で完全な復元ができません．つまり，tokenizerの符号化ははloss-lessではなく，完全に復号できないということです．ここでいう情報とはどのように文字が並んでいたかということです．サブワードは元通り一単語にくっつくのですが，コンマやピリオド，カッコ等の記号(punctuation)はそれぞれにidが振り分けられている(記号は記号で分割される)せいで，うまく元の並びに戻せません．

困りましたね．例えばこのまま解答として扱ってExact matchやf1 score等で評価しようとしても，スコアが適切に評価されない可能性があります．EM scoreに関しては空白をなくす前処理をしてしまえば済みますが，F1やPartial matchに関しては単語ごとに区切って評価するために，たとえ解答があっていたとしても低く評価されることがあります．

# 解決方法
実はhuggingfaceのtokenizer(正確にはpretrained tokenizer)には，"return_offsets_mapping"という引数があり，これをTrue(デフォルトはFalse)にすることで，以下のような出力が得られます．

```python
# 出力が長くなるのでQは省略
encoded = autotokenizer(D, return_offsets_mapping=True)
encoded
>>> {'input_ids': [101, 1109, 127, 119, 21640, 1557, 1263, 1854, 1110, 12444, 170, 2472, 6090, 117, 1105, 1110, 170, 1470, 1812, 117, 1114, 2999, 2420, 9118, 117, 1165, 1185, 3915, 1958, 1132, 1217, 1576, 117, 1105, 1175, 1132, 1242, 15417, 1134, 1169, 1178, 1129, 12269, 1121, 1103, 6090, 119, 102], 
...
'offset_mapping': [(0, 0), (0, 3), (4, 5), (5, 6), (6, 9), (10, 12), (13, 17), (18, 23), (24, 26), (27, 38), (39, 40), (41, 47), (48, 55), (55, 56), (57, 60), (61, 63), (64, 65), (66, 72), (73, 77), (77, 78), (79, 83), (84, 90), (91, 96), (97, 109), (109, 110), (111, 115), (116, 118), (119, 125), (126, 132), (133, 136), (137, 142), (143, 146), (146, 147), (148, 151), (152, 157), (158, 161), (162, 166), (167, 177), (178, 183), (184, 187), (188, 192), (193, 195), (196, 204), (205, 209), (210, 213), (214, 221), (221, 222), (0, 0)]}
```
なにやら'offset_mapping'とやらがinput_idsたちの出力に追加されていますね．

```python
offsets = encoded['offset_mapping']

start_char, _ = offsets[2] # start_idx
_, end_char = offsets[6] # end_idx
pred = D[start_char:end_char]
pred
>>> '6.213 km long'
```
はい，なんと元通りの文字列が得られました．無事解決ですね！
では，何をしているのかを解説します．

# offset_mappingとは

encodedに含まれていたoffset_mappingの正体は，input_idsやattention_maskたちと同じ長さを持つタプルのリストです．

各タプルは2つの要素が含まれており，これはそのindexにおける入力文字列の開始・終了indexを示しています．これはつまり，input_ids等におけるindexを入力文字列のindexに変換するためのタプルということになります．

直上のコードでは，モデルの予測した解答位置のindexにあるタプルの1つ目，すなわち文字列の開始位置を参照('start_char')しています．逆も然りです．これにより得られたindexを文書でスライスすると，きれいな形の解答が得られます．

ちなみに，tokenizerにquestionとcontextのようにSEPで区切った2つの系列を渡した場合でも，その系列ごとにoffset mappingしてくれるので，スライスするときはそのままcontext[start:end]のようにしても大丈夫です．

```python
t1 = 'I found you at the shopping mall yesterday'
t2 = "Seriously? I thoguht you were in school then, weren't you?"
offsets = tokenizer(t1, t2, return_offsets_mapping=True)['offset_mapping']
offsets
>>> [(0, 0), (0, 1), (2, 7), (8, 11), (12, 14), (15, 18), (19, 27), (28, 32), (33, 42), (0, 0), (0, 9), (9, 10), (11, 12), ..., (0, 0)]
# t1からt2に移ったところから，indexはリセットされている


start_char, _ = offsets[10]
_, end_char = offsets[-2]
pred = t2[start_char:end_char] # スライスするのはt2からで良い
pred
>>> "Seriously? I thoguht you were in school then, weren't you?"
```

# 最後に
もっと詳しく知りたい方は公式のドキュメントを参照してください．
https://huggingface.co/course/chapter6/3b?fw=pt#using-a-model-for-question-answering

ありがとうございました！