プログラミング

複数の文字列を仮名ラベルに置き換えて対応表で元に戻す方法(Python)

複数の文章を「誰が書いたか」を伏せたまま比較・評価したい、という場面があります。応募作品のブラインド審査、著者名を隠した査読、社名を伏せた複数ベンダーの提案比較、2つのキャッチコピー案のどちらが良いかを先入観なしで選ぶ、といったケースです。

こうしたブラインド化を実装するには、対象をA・B・C…のような仮名ラベルに置き換え、あとから「どのラベルが誰だったか」を突き合わせる仕組みが必要になります。

この記事では、Pythonの標準ライブラリを用いて、次の流れを実装します。たとえば、3名から集めた提案を、どの提案者のものか伏せて評価したい、という状況です。手元には「提案者(伏せたい名前)」と「本文(見せる中身)」の組が複数あります。

  1. ラベルを割り当てる: 各提案にA・B・C…のようなラベルを付け、提案者名のかわりにこのラベルで呼びます。
  2. 1つにまとめる: 提案者名の代わりにラベル付きの本文を縦に並べ、評価者に渡す1枚のテキストにします。あわせて「どのラベルが誰か」の対応表を手元に残します。
  3. 元に戻す: 評価が終わったら、対応表を使って結果の中のラベルを提案者名に戻します。

2でできる「評価者に渡すテキスト」は、次のような見た目です。

A案
社員食堂を週替わりの投票制にして満足度を上げる。

B案
オフィスに仮眠スペースを設け、午後の生産性を高める。

3の「元に戻す」は素朴に戻すと落とし穴があるため、丁寧に扱います。標準ライブラリを使用するため、インストール等は不要です。

動作確認

Python3.12で実施しています。

※ この記事の動作確認は2026年6月末に実施しています。

目次

はじめに(素性が見えると評価は歪む)

人でも自動処理でも、評価者に「これは誰の作品か」が見えていると、判断がその素性に引きずられることがあります。有名な書き手だから甘くなる、知らない名前だから警戒する、自分が関わったものだから贔屓する。いずれも内容そのものとは関係のないバイアスです。

これを避ける古典的な方法がブラインド化です。評価者には中身だけを見せ、誰のものかはA・B・C…などで記号化することで伏せます。評価が終わってから、記号と実体の対応表を使って「Aは〇〇さんだった」と突き合わせます。

実装に必要な部品は次の2つです。

項目内容
対応表A→山田、B→田中のような「ラベルから実体への辞書」
置換処理実体名をラベルに置き換えて隠す(仮名化)/評価結果のラベルを実体名に戻す(復元)

仮名ラベルを作る

A・B・C…というラベルは文字コードで作成するのが手軽です。chr(i)は整数のコードポイント(文字に割り当てられた数字)iに対応する1文字を返します。Aのコードポイントは65なので、chr(65)Achr(66)Bになります。

0から数えたインデックスiに対してchr(65+i)とすれば、Aから順にラベルが得られます。

def make_labels(n: int) -> list[str]:
    """0..n-1 に対応する A, B, C, ... のラベルを返す(n は 26 以下を想定)"""
    return [chr(65 + i) for i in range(n)]

print(make_labels(3))  # ['A', 'B', 'C']

65という数字がやや唐突なので、65 == ord("A")であること(ordchrの逆で、文字からコードポイントを返す関数)を使い、chr(ord("A") + i)と書くと「Aからi個進めた文字」という意図がそのまま読めます。どちらでも動作は同じです。

ただし、注意点として、このやり方はAからZ(26件)までしか使えません。chr(65 + i)iが26以上になるとAZの範囲を超え、[\といった記号が出てきます。

print([chr(65 + i) for i in range(28)])
# ['A', 'B', ..., 'Z', '[', '\\']   ← 26件目(Z)の次から崩れる

ブラインド評価で対象が26件を超えることは多くありませんが、もし超えうるなら、表計算ソフトの列名(A, B, …, Z, AA, AB, …)と同じ方式に拡張しておくと安全です。

def label_for(i: int) -> str:
    """0->A, 25->Z, 26->AA, 27->AB ... のラベルを返す"""
    s = ""
    i += 1
    while i > 0:
        i, r = divmod(i - 1, 26)
        s = chr(65 + r) + s
    return s

print([label_for(i) for i in (0, 1, 25, 26, 27, 52)])
# ['A', 'B', 'Z', 'AA', 'AB', 'BA']

ここで使っているdivmodは、Pythonの組み込み関数です。divmod(a, b)は「abで割った商」と「余り」を(商, 余り)というタプルで返します。

label_for全体としては、数を「各桁をAZで表す26進数」に変換するイメージです。divmodで得た余りを1桁分の文字(chr(65 + r))に変え、残りの商を次の桁の計算へ回す、という処理を商が0になるまで繰り返すことで、AAABのような複数文字のラベルを作ります。

なおi += 1i - 1は、Zlabel_for(25))の次がAAになるようにするための調整です。インクリメント(i += 1)を削除してdivmodの引数をiにすると、Zの次はBAになります。

以降は説明を簡単にするため、AZの範囲(chr(65 + i))で進めます。多文字ラベルでも壊れない書き方は、後半の「戻す」処理でまとめて確認します。

対応表を作り、仮名化テキストを組み立てる

入力として「実体名→本文」の辞書を受け取り、次の2つを返す関数を作ります。

  • 仮名化した1つのまとめテキスト(評価者に見せるもの)
  • 「ラベル→実体名」の対応表(あとで戻すときに使うもの)
def anonymize(items: dict[str, str]) -> tuple[str, dict[str, str]]:
    """
    items: {実体名: 本文}
    戻り値: (仮名化したまとめテキスト, ラベル→実体名 の対応表)
    """
    labels = make_labels(len(items))
    label_to_source: dict[str, str] = {}   
    blocks: list[str] = []
    for label, (source, body) in zip(labels, items.items()):
        label_to_source[label] = source # 対応表:ラベル -> 実体名
        blocks.append(f"{label}案\n{body}")
    blinded = "\n\n".join(blocks) # "\n\n"の区切りでリストの要素を結合
    return blinded, label_to_source

zip(labels, items.items())で「ラベル」と「(実体名, 本文)」を1対1に組にして回しています。各実体について対応表のエントリ(label → source)を記録しつつ、本文の前にラベルだけを見出しとして付けた塊(ブロック)を作り、最後に連結して1つのテキストにしています。

使い方は以下のようになります。

proposals = {
    "山田": "社員食堂を週替わりの投票制にして満足度を上げる。",
    "田中": "オフィスに仮眠スペースを設け、午後の生産性を高める。",
    "佐藤": "通勤定期代を支給し、リモート併用を前提に働き方を再設計する。",
}

blinded, mapping = anonymize(proposals)
print(blinded)
print(mapping)

出力は次のようになります。評価者にはこのblindedだけを渡せば、提案者名は伏せられます。

A案
社員食堂を週替わりの投票制にして満足度を上げる。

B案
オフィスに仮眠スペースを設け、午後の生産性を高める。

C案
通勤定期代を支給し、リモート併用を前提に働き方を再設計する。
{'A': '山田', 'B': '田中', 'C': '佐藤'}

対応表mappingは評価者には渡さず、こちら側で保持しておきます。評価結果を受け取ってから、これを使ってABCを実名に戻します。

なお、入力(items)を「実体名→本文」の辞書にしたので、同じ名前の提出者が2人いると辞書のキーが衝突します。同名がありうるなら、itemsを辞書ではなく[(実体名, 本文), ...]のタプルを要素とするリストで受け取るなどが考えられます。この場合、以下のように書き換えれば、それ以外はそのまま使えます。

for label, (source, body) in zip(labels, items):   # 辞書版の items.items() → items

評価後にラベルを実体名に戻す

素朴な方法

評価者からは、ラベルを使ったテキストが返ってくる想定です。たとえば「総合ではA案が最も優れている」のような講評や、「1位:B、2位:A、3位:C」のような順番です。

これを利用者に見せるときは、A・B・Cを実名に戻したくなります。一番素朴な方法は、対応表を回しながら文字列置換str.replaceをかける方法です。

def restore_naive(text: str, mapping: dict[str, str]) -> str:
    """text中のラベルを実体名に置換する"""
    result = text
    for label, source in mapping.items():
        result = result.replace(label, source)
    return result


verdict = "総合ではA案が最もバランスが良い。"
print(restore_naive(verdict, {"A": "山田", "B": "田中", "C": "佐藤"}))

結果は以下になります。

総合では山田案が最もバランスが良い。

単純な文ならこれで戻せます。しかし、この方法には上手くいかないケースが2つあります。

■ 置換結果に別のラベルが紛れ込む(連鎖置換)

str.replaceを順番にかけると、前の置換で生まれた文字列を、次の置換が再びスキャンしてしまうことがあります。たとえば実体名に、たまたま別のラベルと同じ文字が含まれている場合です。

mapping = {"A": "B社", "B": "C社"}

print(restore_naive("最優秀はA。", mapping))
# 最優秀はC社社。   ← 壊れている

これはラベルAが"B社"に変換された後、"B社"のBをラベルと誤認識して"C社"に置換しているためです。

実体名がAZの文字をたまたま含むだけで、こうした連鎖が起きます。

■ 無関係な箇所まで巻き込む(部分一致)

もう1つは、ラベルが文中の無関係な箇所にも一致してしまう問題です。

mapping = {"A": "山田", "B": "田中"}
src = "Bは完成度が高くA評価。Aは惜しいがB評価。"

print(restore_naive(src, mapping))
# 田中は完成度が高く山田評価。山田は惜しいが田中評価。   ← A評価/B評価まで化けた

A評価AB評価Bまで置換されてしまいました。ラベルが「1文字」だと、文章中に普通に登場するアルファベットと区別がつきません。

安全に戻す方法

2つの問題をそれぞれ別の方法を用いて解消します。

■ re.subで1回のスキャンにまとめる(連鎖置換の解消)

連鎖置換は「置換を何回も重ねがけする」ことが原因であったため、入力を1回だけ操作し、見つかったラベルをその場で実名に差し替えるようにします。標準ライブラリのre(正規表現モジュール)のre.subを使うと、これを一発で書けます。re.subは置換結果を再びスキャンすることはないため挿入した文字列が再び置換対象になることはありません。

import re

def restore_pass(text: str, mapping: dict[str, str]) -> str:
    """対応表のキーを1スキャンでまとめて実体名へ置換する"""
    keys = sorted(mapping, key=len, reverse=True)            # 長いキーを先に試す
    pattern = re.compile("|".join(re.escape(k) for k in keys))
    return pattern.sub(lambda m: mapping[m.group(0)], text)

まず、key=lenで長さを元にソートしています。reverse=Trueとすることで、長いキーが前に並ぶようにしています。これは、AAAのように一方が他方の一部になるラベルでの取り違えを防ぐためです。

次に、キー(ラベル)を取り出して、re.escapeによりキーに正規表現の特殊文字(.(など)が混ざっていてもそのまま文字として扱えるようにエスケープします。そして、"|".join(...)で「いずれかのキーに一致」というパターンを作ります。

re.compileは引数にパターン文字列を指定して、そのパターン文字列を扱うためのメソッドをもつオブジェクト(型はre.Pattern)を作成する関数です。パターン文字列は|で区切ることで、「区切られた文字列パターンのいずれかに一致する場合に」という意味になります。得られたオブジェクトに対して、.sub().match().findall()などを呼んで、検索や置換を行います。

pattern.sub(関数, text)は、textの中でパターンに一致した箇所を置き換えます。第1引数には、「一致した文字列に対応する、置換後の文字列を返す関数」を与えます。今回のlambda m: mapping[m.group(0)]は、一致した文字列をm.group(0)で取り出し、それを対応表mappingで引いて実体名を返すので、一致したラベルがその実体名に置き換わります。

■ ラベルを記号で囲む(部分一致の解消)

部分一致への現実的な対策は、ラベルを、ふつうの文章にはまず出てこない形にすることです。たとえばA単体ではなく【A】のように記号で囲めば、A評価Aのような無関係な箇所と一致しなくなります。

変えるのはラベルの作り方だけです。make_labelsAではなく【A】を返すようにすれば、anonymizeは無変更のまま、見出しもキーも囲みラベルになります。

def make_labels(n: int) -> list[str]:
    return [f"【{chr(65 + i)}】" for i in range(n)]   # 【】で囲む

print(make_labels(3))  # ['【A】', '【B】', '【C】']

仮名化を行うanonymizeと戻す側のrestore_passはそのまま使えます。re.escapeを通しているのでのような記号も安全に扱えます。

mapping = {"【A】": "山田", "【B】": "田中"}
src = "【B】は完成度が高くA評価。【A】は惜しいがB評価。"

print(restore_pass(src, mapping))
# 田中は完成度が高くA評価。山田は惜しいがB評価。

結果として正しく反映されます。

補足:日本語では\b(単語境界)が当てにならない

「無関係なAとの衝突を避けるなら、正規表現の単語境界\b\bA\bのように囲めばよいのでは」と思うかもしれません。これは英文では有効ですが、日本語混じりの文では当てになりません\bは「\w(英数字・アンダースコアなどの単語構成文字)とそれ以外の境目」を指しますが、Pythonの\wはUnicodeの単語構成文字、つまり日本語の仮名や漢字にも一致するためです。

print(re.findall(r"\w", "級は7A"))
# ['級', 'は', '7', 'A']   ← 漢字・かなも \w 扱い

print(re.sub(r"\bB\b", "X", "Bは完成度が高くB評価"))
# Bは完成度が高くB評価   ← 1つも置換されない

Bの隣が(どちらも\w扱い)だと、そこに単語境界が立たず、\bB\bは一致しません。日本語が混ざる前提では、\bに頼らず【A】のように記号で囲む方が確実です。

仮名化から復元までを通しで流す

ここまでの「囲みラベル版」を使って、仮名化から復元までを通した実装です。

import re


def make_labels(n: int) -> list[str]:
    return [f"【{chr(65 + i)}】" for i in range(n)]   # 【】で囲む

# 仮名化して評価者に見せる資料を作成
def anonymize(items: dict[str, str]) -> tuple[str, dict[str, str]]:
    labels = make_labels(len(items))
    label_to_source: dict[str, str] = {}
    blocks: list[str] = []
    for label, (source, body) in zip(labels, items.items()):
        label_to_source[label] = source
        blocks.append(f"{label}案\n{body}")
    return "\n\n".join(blocks), label_to_source


def restore_pass(text: str, mapping: dict[str, str]) -> str:
    keys = sorted(mapping, key=len, reverse=True)
    pattern = re.compile("|".join(re.escape(k) for k in keys))
    return pattern.sub(lambda m: mapping[m.group(0)], text)

# 提案者と提案内容
proposals = {
    "山田": "社員食堂を週替わりの投票制にして満足度を上げる。",
    "田中": "オフィスに仮眠スペースを設け、午後の生産性を高める。",
    "佐藤": "通勤定期代を支給し、リモート併用を前提に働き方を再設計する。",
}
# 仮名化と対応表の取得
blinded, mapping = anonymize(proposals)
print(blinded)  
print()

# 受け取った講評のラベルを実名に戻す
verdict = "総合では【B】案が最も実用的。次点は【A】案、【C】案は要再検討。"
print(restore_pass(verdict, mapping))

出力は次の通りです。

【A】案
社員食堂を週替わりの投票制にして満足度を上げる。

【B】案
オフィスに仮眠スペースを設け、午後の生産性を高める。

【C】案
通勤定期代を支給し、リモート併用を前提に働き方を再設計する。

総合では田中案が最も実用的。次点は山田案、佐藤案は要再検討。

評価者には誰のものか分からないテキストを見せ、返ってきた講評は実名に戻して関係者に共有できました。

出力形式を相手に強制できない場合

記号で囲む方法は強力ですが、万能ではありません。評価結果のテキストを作るのが自分の制御下にない相手(人間や外部の処理)だと、こちらが【A】を期待していても、相手が囲み記号を外して素のAだけを書いてくる、あるいは「案A」「(A)」のように勝手な書式で書いてくることがあります。

そのようなときは、講評や順位を決まったフォーマットで出力させ、そこからラベルを構造的に取り出して対応表で引くほうが確実です。たとえば「N位: ラベル」という形式で書いてもらえば、講評に素のAがまぎれていても、順位の行だけを取り出せます。

import re

# 評価者に「N位: ラベル」の決まった形式で書いてもらう(相手が 【】 を外して素の A で書く場合)
verdict = """
1位: A
2位: C
3位: B
講評: Aは説得力があるが、Cの実現性も高い。"""

mapping = {"A": "山田", "B": "田中", "C": "佐藤"}

# 「N位: ラベル」の行だけをフォーマットに基づいて取り出す
ranking = re.findall(r"\d+位:\s*([A-Z]+)", verdict)
print(ranking)                                # ['A', 'C', 'B']
print([mapping[label] for label in ranking])  # ['山田', '佐藤', '田中']

re.findallで「N位:に続くラベル」だけを拾っているので、講評にまぎれた素のAは対象になりません。「自由文を置換で直す」よりも「形式を決めて解析する」ほうが壊れにくい、という方針です。この場合、ラベル作成と仮名化部分はこれまで通りとし、戻す方法のみを変更することになります。評価する側の特性によっては、置換よりこの方向のほうが安定します。

用語の整理

最後に、用語を整理します。

用語説明
ブラインド化素性を伏せて、先入観なしに評価させることを意味します。評価側に着目した場合にはブラインドレビューとも言います。また、心理学や臨床試験などでは同じ考え方を盲検と呼びます。
仮名化氏名などの識別子を、人工的なコードや別名に置き換える方式です。別に保存した対応表やキーと突き合わせて戻せる場合、仮名化と呼びます。なお、完全に戻せない場合は匿名化と呼び、この2つの用語は全く異なるものです。

また、実データをそれらしい別の値へ置き換える操作を、広くデータマスキングと呼ぶこともあります。

なお、本記事の「戻す」処理では、対応表のキーを|でつないだ正規表現をre.subに1回かけて、まとめて置換しました。このように多数のキーワードを一度の走査でまとめて検出・置換する代表的なアルゴリズムには、Aho–Corasick法(エイホ–コラシック法)という名前があります。キーの数が非常に多くなり、正規表現での置換が重く感じられてきたら、この名前で専用ライブラリを検索するとよいかもしれません。

まとめ

本記事では、複数の文字列を仮名ラベルに置き換え、対応表で元に戻す手順を見てきました。以下に要点をまとめます。

  • ラベルはchr(65 + i)ABC…と作れる。ただしZ(26件)までで、超えるなら列名方式(label_for)に拡張する。
  • 「ラベル→実体名」の対応表を別に保持し、評価者には仮名化テキストだけを渡す。
  • 戻す処理をstr.replaceの単純な繰り返しで書くと、連鎖置換部分一致が問題になる。
  • 連鎖置換はre.subで「1回のスキャンでまとめて置換」すれば解消できる。代表的なアルゴリズムはAho-Corasick法(エイホ–コラシック法)がある。
  • 部分一致は、ラベルを【A】のように記号で囲み、そのトークンをキーにすることで避けられる。日本語混じりでは\b(単語境界)が当てにならない点にも注意する。
  • 評価する側の特性によって、置換ではなく「フォーマットを決めて構造的に取り出す」ほうが堅牢になる場合がある。

-プログラミング
-