spaCy固有表現抽出(+Presidio)によるドキュメントの情報漏えいリスクチェック支援

R&D チームの徳田(@dakuton)です。記事冒頭に書くことが思いつかなかったので先日のGPT記事にあるサンプルを使ってみました。
試してみたところ、Tech Blog記事っぽい出力にはなりました。

  • 入力(Prompt): R&D チームの徳田([@dakuton](https://twitter.com/dakuton))です。
  • 出力: 皆さんおひさしぶりです。遅くなりましたが、11/18(金)に行われましたRuby Machine Learningの勉強会の模様を記事にしました。

サンプルは下記参照

tech-blog.optim.co.jp

背景

本題ですが、目的は本記事タイトルのとおりです。

技術要素としては下記と同じような内容です。本記事ではこれをspaCyのエコシステム(spaCy日本語モデルPresidio)で実現する方法について紹介します。

また、人が作成したドキュメントだけでなく、冒頭の参考記事(GPT紹介)でも述べていたように、GPTなど言語モデルにより生成されたテキストでも固有名詞が含まれることによるリスクがあります。
これは大規模なパラメータを持つ言語モデルが大規模なデータセットとしてWebスクレイピングによる言語コーパスを利用しているためであり、生成結果に下記1,2のリスクに該当するような固有名詞が含まれている場合はダミーであることがわかるデータに置き換えて利用することが求められます。

  1. 本来公開してはいけない情報(営業機密や個人情報)がスクレイピング取得できてしまっていることに起因し、生成されるテキストに他者の機密情報・個人情報が含まれるリスク
  2. 生成されるテキストの一部に実在する用語(人物・組織・サービス名など)が含まれることで、組み合わせとしては架空の内容であったとしても他者の利益(登録商標など)を侵害するリスク
    • 例えば、企業名を手がかりとしてGPTにてテキストを生成させると、プレスリリース風の文が生成されることが多くあります。
    • 生成結果をサンプル文として利用するには本物かどうかの識別が難解なため、手がかりに選択した企業が実際には提供していない製品(架空の製品ではない)や実在しない住所が混在することで誤解を与える可能性があります。

先述の課題のうち、今回は個人情報に関するリスクチェックを試しています。

サンプルコード

日本語テキストで使用する場合も公式サンプルを参考に被疑箇所の抽出(sampleを日本語モデルに変更)や匿名化(sample)は実装可能です。

本記事ではPDF(フォント埋め込み)の被疑箇所抽出に対応しています。
処理順をざっくり記載すると下記のとおりです。1,2は過去記事と同様で、3,4が今回の主な追加要素です。

tech-blog.optim.co.jp

  1. PDFを1ページずつテキストに変換(Poppler pdfinfo,pdftotext利用)
  2. 1ページ内のテキストを読みやすい単位に整形(Bunkai利用)
  3. spaCy固有表現抽出モデルを利用した被疑箇所抽出(presidio_analyzerパッケージ)
    • spaCy日本語モデルによる抽出
    • カスタムルールとしてマイナンバー(個人12桁または法人13桁、うち1桁は検査用数字)のルールベース抽出
  4. プライバシーデータの可能性があるテキストを整形(presidio_anonymizerパッケージ)
    • マスキング(OperatorConfig未指定時のデフォルト)ではなくマーキング(抽出entityラベル+色変更)した結果を出力
import importlib
import os
import pkg_resources
import subprocess
import unicodedata

import plac
from bunkai import Bunkai
from presidio_analyzer import AnalyzerEngine, PatternRecognizer, Pattern
from presidio_analyzer.nlp_engine import NlpEngineProvider
from presidio_anonymizer import AnonymizerEngine
from presidio_anonymizer.entities import OperatorConfig


def pdftotext_by_page(file_path):
    def run_command(command):
        res = subprocess.run(command, text=True, stdout=subprocess.PIPE)
        return unicodedata.normalize('NFKC', "".join(res.stdout)).split("\n")

    pages = [line for line in run_command(["pdfinfo", file_path]) if line.startswith("Pages:")]
    pages = int(pages[0].rsplit()[-1])
    for i in range(pages):
        page_no = str(i + 1)
        yield page_no, run_command(["pdftotext", "-f", page_no, "-l", page_no, file_path, "-"])


def parse_lines_bunkai(lines, bunkai_model):
    lines = [line.strip().replace("\n", "\u2581") for line in lines]
    lines = bunkai_model("".join(lines))
    return [line.strip().replace("\u2581", "").replace(" ", "") for line in lines]


def create_presidio_analyzer(spacy_model_names):
    # 未ダウンロードのモデルファイルがある場合はダウンロード
    for model_name in spacy_model_names:
        if importlib.util.find_spec(model_name) is None:
            os.system("python -m spacy download {}".format(model_name))
            importlib.reload(pkg_resources)

    config_models = [{
        "lang_code": model_name.split("_", 1)[0],
        "model_name": model_name,
    } for model_name in spacy_model_names]
    supported_languages = list({model["lang_code"] for model in config_models})

    provider = NlpEngineProvider(
        nlp_configuration={
            "nlp_engine_name": "spacy",
            "models": config_models,
        }
    )
    analyzer = AnalyzerEngine(
        nlp_engine=provider.create_engine(),
        supported_languages=supported_languages
    )

    # カスタムルームとしてマイナンバーを追加
    analyzer.registry.add_recognizer(PatternRecognizer(
        supported_entity="INDIVIDUAL_NUMBER",
        supported_language="ja",
        patterns=[
            Pattern(name="マイナンバー", score=0.8, regex="[0-9]{11,13}")
        ]
    ))
    return analyzer, supported_languages


def create_presidio_anonymizer():
    def config_for_optim_tech_blog(tag, color):
        # 被疑箇所にタグ挿入(はてなブログで使える文字装飾)
        # https://blog.hatenablog.com/entry/2019/08/20/190500
        return OperatorConfig(
            "custom",
            {"lambda": lambda x: f"<span style='color:{color}'>[{tag}]{x}</span>"}
        )

    anonymizer = AnonymizerEngine()
    operators = {
        # 定義がないものは下記と同値として扱われる(<EMAIL_ADDRESS>などentity_typeで置き換えるルールが適用される)
        # "DEFAULT": OperatorConfig("replace", {"new_value": "<entity_type>"}),
        "PERSON": config_for_optim_tech_blog("名前", "#d00070"),
        "LOCATION": config_for_optim_tech_blog("住所", "#009070"),
        "DATE_TIME": config_for_optim_tech_blog("生年月日", "#0070d0"),
        "PHONE_NUMBER": config_for_optim_tech_blog("電話番号", "#7000d0"),
        "INDIVIDUAL_NUMBER": config_for_optim_tech_blog("マイナンバー", "#d07000"),
    }
    return anonymizer, operators


@plac.annotations(
    src=("pdf", "positional"),
    model=("spaCy model", "option", "m", str)
)
def main(src, model="ja_core_news_trf"):
    bunkai_model = Bunkai()
    analyzer, supported_languages = create_presidio_analyzer(model.split(","))
    anonymizer, operators = create_presidio_anonymizer()

    print(f"### 実行結果({src}, model:[{model}])")
    for page_no, lines in pdftotext_by_page(src):
        print(f"#### Page-{page_no}")
        for line in parse_lines_bunkai(lines, bunkai_model):
            analyzer_results = analyzer.analyze(
                text=line,
                language=supported_languages[0]
            )
            anonymizer_result = anonymizer.anonymize(
                text=line,
                analyzer_results=analyzer_results,
                operators=operators
            )
            if len(anonymizer_result.items) > 0:
                print(anonymizer_result.text + "<br>")
            else:
                print(line + "<br>")


if __name__ == "__main__":
    plac.call(main)

サンプルデータ

住所・氏名などの個人情報(ダミー)を含むファイルとして、今回は国税庁の年末調整資料を利用しました。

$ wget https://www.nta.go.jp/taxes/tetsuzuki/shinsei/annai/gensen/pdf/r3bun_02.pdf
$ python sample_presidio.py r3bun_02.pdf

実行結果

記事内では1ページ目の数行分のみ記載しています。また、初回実行時は指定したspaCyモデルダウンロードに時間がかかります。
サンプル内でもフリガナが見落とされるなど、抽出精度が100%ではないため、問題リスクを鑑みて人によるレビューをスクリプトに置き換える(なくす)ことは厳しいです。

  • 公式日本語モデルのなかで最も固有表現抽出のベンチマーク精度がよいja_core_news_trfでもf1 scoreが0.82

ただ、文境界判定があまり正確ではない文でもある程度抽出はできているので、ダメそうなページか?直感的にわかるようにする(情報漏えいリスクの早期発見)ための補助ツールとしては利用可能かと思います。

固有表現抽出の結果を直接利用した場合よりもPresidioを経由したほうが便利な要素としては、複数のルールやモデルを併用する場合の重複判定にも対応していることなどが挙げられます。

  • 今回だと数字の羅列が電話番号とマイナンバーのどちらになるか?は、文字数(内包する結果優先)や検出スコア(文字数が同じ場合はスコアが高いほうを優先)にて決定されます。
  • マイナンバー(カスタムルール)の検出スコアを0.8から下げた場合は電話番号として検出されるようになります。

Page-1

[生年月日]令和3年[生年月日]令和3年分所轄税務署長等[住所]麹町1給与の支払者の名称(氏名)給与の支払者給与所得者の扶養控除等申告書の記載例給与所得者の扶養控除等(異動)申告書サトウカズオ(フリガナ)株式会社○○○○[住所]板橋市区町村長給与の支払者の所在地(住所)[名前]明[名前]大[名前]昭平・令あなたの生年月日[名前]佐藤和夫あなたの氏名173所(郵便番号あなたの住[住所]東京都千代田区霞が関3又は[住所]居0014−44101月扶従たる給与につ日いての扶養控除等申告書の提出提出している場合本人あなたとの続柄には、○印を付けてください。
__)配偶者[住所]東京都板橋区大山東町35-1所年[名前]佐藤和夫世帯主の氏名※この申告書の提出を受けた給与の支払者が記載してください。
税務署長の法人(個人)番号[マイナンバー]1122334455667あなたの個人番号[マイナンバー]112233445566有・無の有無あなたに源泉控除対象配偶者、障害者に該当する同一生計配偶者及び扶養親族がなく、かつ、あなた自身が障害者、寡婦、ひとり親又は勤労学生のいずれにも該当しない場合には、以下の各欄に記入する必要はありません。
区分源泉(フ氏等リガナ)名サトウヨウコ控除A対象配偶者個人番あなたとの続柄非居住者生計を一に(平11.1.2生〜平15.1.1生)である親族特定扶養親族する事実21同居老親等その他[名前]明・大昭・平子11・2・4[名前]佐藤隆雄障害者、寡は勤労学生該当者本区分人・(1人)特者(人)同居特別障害者(人)障害氏名他の所得者が控除を受ける扶養親族等[生年月日]令和3年中に異動があった場合に記載してください(以下同じ。
)。
あなたとの続柄生年明・大・昭平・令明・大・昭平・令月・・・[住所]東京都板橋区大山東町35-1裏面の300,000円[住所]東京都板橋区大山東町35-11円記載についてのご注意」の(8)をお読みください。
)異動月日及び事由婦[名前]佐藤隆雄、身体障害者3級身体障害者手帳[生年月日]平成27年4月11日交付ひとり親勤労学生住(注)1源泉控除対象配偶者とは、所得者([生年月日]令和3年中の所得の見積額が900万円以下の人に限ります。
)と生計を一にする配偶者(青色事業専従者として給与の支払を受ける人及び白色事業専従者を除きます。
)で、[生年月日]令和3年中の所得の見積額が95万円以下の人をいいます。
(注)2同一生計配偶者とは、所得者と生計を一にする配偶者(青色事業専従者として給与の支払を受ける人及び白色事業専従者を除きます。
)で、[生年月日]令和3年中の所得の見積額が48万円以下の人をいいます。
所又は居控氏所除を受名けるあなたとの続柄他の所得者住所又は居所異動月日及び事由控除対象外[生年月日]令和3年中の国外扶養親族所得の見積額異動月日及び事由○住民税に関する事項(この欄は、地方税法第45条の3の2及び第317条の3の2に基づき、給与の支払者を経由して市区町村長に提出する給与所得者の扶養親族申告書の記載欄を兼ねています。
)(氏1416歳未満の扶養親族(平18.
1.2以後生)[名前]フリガナサトウマサル佐藤勝)名個人番号あなたとの続柄生年月日住所又は居所平[マイナンバー]556677889900子・[名前]令18・10・15[住所]東京都板橋区大山東町35-10円平2・・・円・・[名前]円令平3・◎この申告書の記載に当たては円特定扶養親族・寡日・1234KokuzeiStreet,・・・USA障害者又は勤労学生の内容(この欄の記載に当たっては、裏面の「2同一生計扶養親族配偶者(注2)一般の障害者別所特定扶養親族上の該当する項目及び欄にチェックを付け、()内には該当する扶養親族の人数を記入してください。

その他: GiNZAを利用したい場合にも対応可能か?

GiNZAについてはspaCyのパイプラインで動作するように実装されているものの、GiNZAの固有表現として管理されているラベルがspaCy直下のモデルでの固有表現ラベルと一致しないため、今回のコードにてモデル名をja_ginzaに変更するだけでは被疑箇所を抽出できません。
これは過去記事日本語正式サポートされた自然言語処理ライブラリspaCyのStreamlit可視化が超お手軽だった > GiNZAとの結果比較 で解説した内容と同じような結果です。
利用したい場合は、カスタムのEntityRecognizerを作成し、spaCyの固有表現ラベルと紐づく形への変換する対応が少なくとも必要になるでしょう。(GiNZAのENE_ONTONOTES_MAPPING参照)

終わりに

過去、Ruby Machine Learningの勉強会を開催したことはありませんが、オプティムでは定期的に勉強会を実施しています。

optim.connpass.com

また、オプティムでは技術をいい感じに使って実在する問題解決に貢献できるエンジニアを募集しています。

www.optim.co.jp