査読付き論文28本を教材にした83分の動画を作りました。画面に出る英文133本は、すべて論文の要旨からの逐語です。
……という文をそのまま信じてもらうのは無理です。私も他人が書いていたら信じません。
なので、逐語であることをビルド時に機械で検査して、通らなければ台本が組めないという作りにしました。この記事はその話です。
対象は動画制作ですが、考え方は「LLMが生成した文章の中に、原典からの引用が混ざっている」あらゆる場面で同じです。
何が問題か
LLMに論文の要旨を渡して「重要な文を抜き出して」と頼むと、だいたい良い仕事をします。でも時々、微妙に書き換えます。
- 語順が自然な方に直る
- 二つの文が一つにまとまる
- 冠詞や前置詞がひとつ落ちる
- 数字の桁区切りが落ちる
どれも、目で見て気づけません。0.001% が .001% になっていても、読み流せば通ります。実際に私は、自作の前処理でこれをやりました(後述)。
冠詞ひとつでも意味は変わります。別の回で a large recession を「大不況」と訳しかけました。不定冠詞の付いた「ある大きな景気後退」と、2008年の The Great Recession は別物です。訳のほうの話ですが、原文が手元に残っていなければ、この取り違えは検証すらできません。
そして一度混ざると、「これは引用です」という主張全体が嘘になります。 132本が正しくても、1本が書き換わっていれば、視聴者から見れば全部が疑わしい。
人の目で確かめる方式は、100本を超えたところで必ず崩れます。だから機械でやります。
やったこと
台本データ(章ごとのJSON)と、APIから取得した要旨を突き合わせます。
def norm(s):
"""比較用に整える。引用符・ダッシュ・空白だけを揃え、語は変えない"""
s = unicodedata.normalize("NFKC", s)
for a, b in [("‘", "'"), ("’", "'"), ("“", '"'), ("”", '"'),
("–", "-"), ("—", "-"), ("−", "-"), (" ", " ")]:
s = s.replace(a, b)
return re.sub(r"\s+", " ", s).strip()
正規化で触ってよいのは、表記の揺れだけです。引用符の種類、ダッシュの種類、空白の連続、NBSP。これらは出版社ごとに違うので揃えないと比較になりません。
逆に、語そのものを触ってはいけません。 小文字化も、ステミングも、句読点の除去もしません。そこを緩めると検査の意味がなくなります。「だいたい合っている」を通す検査は、何も保証しません。
検査は4つです。
1. 英文が要旨の中に literal に存在するか
ab = norm(a["abstract"])
en = norm(c[k]["en"])
if en not in ab:
...
in だけです。部分文字列として存在しなければ落ちます。
落ちたときに「無い」とだけ言われても直せないので、二分探索でどこまで一致したかを出します。
lo, hi = 0, len(en)
while lo < hi:
mid = (lo + hi + 1) // 2
if en[:mid] in ab: lo = mid
else: hi = mid - 1
print(f' 一致したのはここまで: …{en[max(0,lo-60):lo]}')
print(f' ここから違う : {en[lo:lo+60]}…')
これが効きました。ずれた位置が出るので、たいてい一目で原因が分かります。
2. 意味のかたまりに割った断片が、繋ぎ直すと元に戻るか
この動画は英文を意味のかたまりに割って、英語の語順のまま訳を置きます。割り方を間違えると、原文にない空白が入ったり語が落ちたりします。
joined = " ".join(x["en"] for x in c[k]["chunks"])
if norm(joined).split() != en.split():
...
.split() で比較しているのは、かたまりの境界の空白だけは許すためです。語の列が一致しなければ落ちます。
これで4回止まりました。全部、em dash をまたいで区切っていたケースです。— の前後で割ると、繋ぎ直したときに空白が1つ増える。ダッシュはかたまりの内側に入れる、という規則に直しました。
3. 強調する語が、その英文の中に実在するか
1文につき1語、画面で光らせる単語を選んでいます。この語が英文に無いと、光らせる処理が空振りして、画面上は普通の文が出るだけになります。エラーは出ません。静かに壊れる種類のバグです。
w = c[k]["word"].lower()
stem = re.sub(r"(ing|ed|es|s|d)$", "", w)
if stem not in en.lower():
...
ここだけは小文字化と簡易ステミングを許しています。文頭の大文字と、replaced / replace の揺れを通すためです。検査の厳しさを緩める箇所は、緩める理由を書いておかないと、後で自分が理由を忘れて全体を緩めます。
4. 考察に引く一節が、その英文の中にあるか
章末の考察で英文の一部を引き直しています。これも同じく in で見ます。
要旨を取るところも、自己テストで守る
Crossref から返ってくる要旨は JATS XML です。参照番号が <jats:sup>1,2</jats:sup> で入っています。これをタグごと落とすと、参照番号だけが本文に残ります。
最初に書いた素朴な実装は、こうなりました。
| 元 | 壊れた結果 |
|---|---|
0.001% |
.001% |
1,000 |
,000 |
99.16% |
.16% |
タグを消してから「行頭の数字は参照番号だろう」と落としていたせいです。数値を作り変えるという、いちばんやってはいけない壊れ方でした。
直し方は単純で、<jats:sup> を中身ごと先に落としてから、残りのタグを外します。
def _strip_jats(a):
a = re.sub(r"<jats:sup>.*?</jats:sup>", "", a, flags=re.S) # 上付き=参照番号
...
そして自己テストを4件書きました。
def _selftest():
cases = [("<jats:p>We replaced just 0.001% of tokens<jats:sup>1,2</jats:sup> here.</jats:p>",
"We replaced just 0.001% of tokens here."), ...]
異常な結果ほど、自分のバグを疑うというのは何度も痛い目に遭って覚えたことです。「0.001%という数字はさすがに小さすぎないか」と思ったときに、論文を疑う前に自分のコードを疑うべきでした。
DOIも、記憶で書いてはいけない
最初、28本のDOIを記憶で書きました。7本が間違っていました。 うち1本(10.1126/science.adn1146)は、海洋保護区に関する全く別の論文を指していました。
直し方は、DOIを手で書くのをやめることです。OpenAlex に題名のキーワードで問い合わせ、APIが返した候補の中からしか選べないスクリプトに置き換えました。
title keywords → OpenAlex → 候補のDOI一覧 → その中から選ぶ
人が入力する余地を無くすと、この種の間違いは構造的に起きなくなります。
なお、4つの分野を OR でつないだ検索クエリを OpenAlex に投げたところ、4回とも同じ結果が返ってきました。OR が期待どおりに効いていないように見えたので、分野ごとに分けて投げる形に変えています(APIの仕様としてそう決まっているかまでは確認していません)。
結果
5つのパート、28章、133本の英文。全部通りました。
5章 / 英文19本 を照合
すべて要旨の逐語と一致。かたまり・難語・考察の引用も通った
7章 / 英文35本 を照合
...
通ったことより、通らなかったときに止まることのほうが価値があります。作っている最中に、上に書いた種類の間違いが十数回止まりました。人の目で見ていたら、全部そのまま出ていたはずです。
まとめ
- 「引用です」と主張するなら、引用であることを検査する。主張と検査は別物
- 正規化で触ってよいのは表記の揺れだけ。語を触った瞬間に検査が意味を失う
- 落ちたときにどこがずれたかを出す。出ないと直せないので検査が外される
- 静かに壊れる箇所(光らない、繋がらない)こそ検査する。エラーが出るものは放っておいても気づく
- 前処理には自己テストを付ける。とくに数値を触る処理には必ず
- 外部識別子(DOI、URL、ID)は記憶で書かない。APIが返したものしか選べない作りにする
できた動画はこちらです。
- 日本語だけの完全版(43分) https://www.youtube.com/watch?v=qq8U4W4Iung
- 英語の原文のまま聴く版(83分) https://www.youtube.com/watch?v=e-iBL3GhZsY
出典28本のDOIは、どちらも説明欄に全部置いてあります。