7
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

この記事のサンプルに出てくる氏名・電話番号・住所・カード番号・マイナンバーは、すべて架空のテスト用ダミーです。実在の人物やカードとは一切関係ありません。

はじめに

今私が携わっている案件では、音声の文字起こしログの個人情報をマスクしたいという要望が上がってます。
生成AIを用いれば高精度でマスキングできそうですが、コストの兼ね合いもあり、まずは AI を使わずに既存のフレームワークだけでどこまで消せるかを押さえておきたかったです。
今回は、その調査の一環で「Microsoft Presidio」というフレームワークを用いて、どこまでマスキングできるかを試してみました。

Microsoft Presidio とは

Microsoft Presidio は、テキストや画像から個人情報(PII)を検出・マスキングするためのオープンソースフレームワークです。自然言語処理やパターンマッチングを用いて、個人情報を特定し、マスクすることができます。日本語のNLPエンジンもサポートされていたため、今回の検証に採用しました。

検証方法

1. 個人情報を盛り込んだ台本を用意する

カスタマーサポートの通話を想定し、人名・電話番号・住所・メール・カード番号・マイナンバーなどを一通り詰め込んだ台本を作りました。

オペレーター: お電話ありがとうございます。カモシカモバイル カスタマーサポート、担当の山田と申します。本日はどういったご用件でしょうか。

お客様: あ、すみません、引っ越したので登録住所の変更をお願いしたいんですけど。

オペレーター: かしこまりました。まず本人確認をさせてください。お名前とご登録のお電話番号をお願いできますか。

お客様: 佐藤健一です。電話番号は 03-1234-5678 です。

オペレーター: ありがとうございます、佐藤健一様ですね。会員番号はお手元にございますか。

お客様: えーと、会員番号は CS-2026-000609 です。あと、メールアドレスも変えたくて、新しいのは kenichi-sato@example.com です。

オペレーター: 承知しました。では新しいご住所をお願いします。

お客様: 東京都港区高輪2丁目21番1号、THE LINKPILLAR 1 NORTH 11階です。引っ越したのが先月の5月20日でした。

オペレーター: ありがとうございます。お支払いについてですが、今は下4桁が1111のクレジットカードでご登録ですね。

お客様: あ、そのカード解約しちゃったんです。新しいカードが 4111-1111-1111-1111、有効期限が2028年3月です。

オペレーター: 承知しました。今月のご請求は12,800円となっております。本日付けで変更を反映いたします。

お客様: あと、マイナンバーの確認も必要って言われたんですけど、123456789012で合ってますか。

オペレーター: はい、確認できました。それでは佐藤様、変更内容の確認メールを kenichi-sato@example.com 宛にお送りします。ご不明点があれば、サポート直通の 03-1111-2222 までお願いいたします。

台本に仕込んだ個人情報を整理すると次のとおりです。

# 種類 台本中の値 想定する Presidio エンティティ
1 人名(オペレーター) 山田 PERSON
2 人名(顧客・フルネーム) 佐藤健一 PERSON
3 組織名 カモシカモバイル ORGANIZATION(日本語はデフォルト弱い想定)
4 電話番号(顧客) 03-1234-5678 PHONE_NUMBER
5 電話番号(サポート直通) 03-1111-2222 PHONE_NUMBER
6 会員番号(独自 ID) CS-2026-000609 カスタム Recognizer が必要
7 メールアドレス kenichi-sato@example.com EMAIL_ADDRESS
8 住所 東京都港区高輪2丁目21番1号 THE LINKPILLAR 1 NORTH 11階 LOCATION
9 日付 5月20日 / 2028年3月 DATE_TIME
10 クレジットカード番号 4111-1111-1111-1111 CREDIT_CARD
11 マイナンバー 123456789012 カスタム Recognizer が必要

2. 台本を読み上げて文字起こしする

この台本を自分で読み上げ、音声入力で文字起こしをしました。今回は、整ったサンプル文ではなく「音声認識を経たログ」を Presidio に渡したいからです。

実際に得られた文字起こし全文がこちらです。結構乱れましたね😅

お電話ありがとうございます。鴨志賀モバイルカスタマースタッポート担当の山田と申します。本日はどういったご予定でしょうか。すいませんでしたので、登録住所の方をお願いしたいんですけど。こうかしこまりました。まず本人確認をさせてください。お名前とご登録の電話番号お願いできますか?佐藤健一です。電話番号は103、1ありがとうございます佐藤健一様ですね会員番号は手元にございますか会員番号はCSの2026のゼローゼロ60609ですあとメイランドです新しいのは県1で、K・ニーナイ、C、ハイフン佐藤S.Oアットマークエグザンプルコムです消しましたでは新しいご住所をお願いします東京都港区高輪2丁目21番1号のさあリンクピラーワンの数11回です。引っ越したのが先月の5月20日でした。ありがとうございますおそらについてですが今下4桁が1111111クレジットカードがクレジットカードがお金買い訳しちゃったんです新しいカードが411111111111111111111111111で有効期限が2020申しました本日のご請求は1万2800円となっております本日付で変更を反映いたしますあとマイナンバーの確認も必要って言われたんですけど123、4、5、678、9012であってますか?はい確認できましたそれでは佐藤様変更内の確認メールをK.NICHIで点15分SATOで佐藤アットマークリズアムトコムアテにお送りいたしますご不明点があればサポッチェクスの10311111の2222までお願いいたします

3. Presidio に通す

文字起こしテキストを Presidio に渡せるようにスクリプトを作成します。

#!/usr/bin/env python
# /// script
# requires-python = ">=3.10,<3.13"
# dependencies = [
#     "presidio-analyzer",
#     "presidio-anonymizer",
#     "click",
#     "ja_core_news_lg @ https://github.com/explosion/spacy-models/releases/download/ja_core_news_lg-3.8.0/ja_core_news_lg-3.8.0-py3-none-any.whl",
#     # "ja_core_news_trf @ https://github.com/explosion/spacy-models/releases/download/ja_core_news_trf-3.8.0/ja_core_news_trf-3.8.0-py3-none-any.whl",
# ]
# ///
"""使い方:
  uv run mask.py            # 同ディレクトリの transcript.txt を読む
  uv run mask.py path.txt   # ファイルを指定
"""
import os
import sys
from pathlib import Path

from presidio_analyzer import (
    AnalyzerEngine,
    Pattern,
    PatternRecognizer,
    RecognizerRegistry,
)
from presidio_analyzer.nlp_engine import NlpEngineProvider
from presidio_analyzer.predefined_recognizers import (
    CreditCardRecognizer,
    DateRecognizer,
    EmailRecognizer,
    PhoneRecognizer,
    SpacyRecognizer,
)
from presidio_anonymizer import AnonymizerEngine

# 環境変数 MODEL で切り替え(MODEL=ja_core_news_trf など)
MODEL_NAME = os.environ.get("MODEL", "ja_core_news_lg")


def build_analyzer() -> AnalyzerEngine:
    # 1) 日本語 spaCy モデルで NLP エンジンを構築する
    configuration = {
        "nlp_engine_name": "spacy",
        "models": [{"lang_code": "ja", "model_name": MODEL_NAME}],
    }
    nlp_engine = NlpEngineProvider(nlp_configuration=configuration).create_engine()

    # 2) ja 用に recognizer を手で組む(定義済みは既定 en なので ja 指定で再登録)
    registry = RecognizerRegistry(supported_languages=["ja"])
    # spaCy NER(人名・地名・組織名など)の結果を Presidio に渡す。こちらも supported_language="ja" で再登録。
    registry.add_recognizer(SpacyRecognizer(supported_language="ja"))
    registry.add_recognizer(CreditCardRecognizer(supported_language="ja"))
    registry.add_recognizer(EmailRecognizer(supported_language="ja"))
    registry.add_recognizer(DateRecognizer(supported_language="ja"))
    registry.add_recognizer(
        PhoneRecognizer(supported_language="ja", supported_regions=["JP"])
    )

    # 3) 日本固有 PII のカスタム recognizer 今回は正規表現ベースでのみ設定。
    # マイナンバー(12桁。区切りの有無・全半角ゆれを許容)
    my_number = PatternRecognizer(
        supported_entity="JP_MY_NUMBER",
        supported_language="ja",
        patterns=[Pattern("my_number", r"\b\d{4}[\s\-]?\d{4}[\s\-]?\d{4}\b", 0.5)],
        context=["マイナンバー", "個人番号"],
    )
    # 会員番号(CS-2026-000609 のような英字+数字+ハイフン)
    member_id = PatternRecognizer(
        supported_entity="JP_MEMBER_ID",
        supported_language="ja",
        patterns=[Pattern("member_id", r"\b[A-Z]{1,4}\d{2,4}[\-]?\d{4,}\b", 0.4)],
        context=["会員番号", "お客様番号"],
    )
    registry.add_recognizer(my_number)
    registry.add_recognizer(member_id)

    return AnalyzerEngine(
        nlp_engine=nlp_engine,
        registry=registry,
        supported_languages=["ja"],
    )


def main() -> None:
    src = (
        Path(sys.argv[1])
        if len(sys.argv) > 1
        else Path(__file__).with_name("transcript.txt")
    )
    text = src.read_text(encoding="utf-8")

    analyzer = build_analyzer()
    results = analyzer.analyze(text=text, language="ja")

    # 検出結果の一覧(採点用)
    print("=== 検出されたエンティティ ===")
    for r in sorted(results, key=lambda x: x.start):
        print(f"{r.entity_type:<16} score={r.score:.2f}  {text[r.start : r.end]!r}")

    # マスキング後テキスト
    anonymized = AnonymizerEngine().anonymize(text=text, analyzer_results=results)
    print("\n=== マスキング後 ===")
    print(anonymized.text)


if __name__ == "__main__":
    main()

検証結果

日本語標準モデル(ja_core_news_lg)での検出結果

=== 検出されたエンティティ ===
PERSON           score=0.85  '志賀モバイルカスタマースタッポート'
PERSON           score=0.85  '山田'
PERSON           score=0.85  '佐藤健一'
PERSON           score=0.85  '佐藤健一'
ORGANIZATION     score=0.85  'K・ニーナイ'
PERSON           score=0.85  'ハイフン佐藤S.Oアットマークエグザンプルコム'
LOCATION         score=0.85  '東京都港区高輪2丁目21番1号'
DATE_TIME        score=0.85  '5月20日'
PERSON           score=0.85  '佐藤'
ORGANIZATION     score=0.85  'K.NICHI'
DATE_TIME        score=0.85  '15分'
ORGANIZATION     score=0.85  'SATO'
ORGANIZATION     score=0.85  '佐藤アットマークリズアムトコムアテ'

=== マスキング後 ===
お電話ありがとうございます。鴨<PERSON>担当の<PERSON>と申します。本日はどういったご予定でしょうか。すいませんでしたので、登録住所の方をお願いしたいんですけど。こうかしこまりました。まず本人確認をさせてください。お名前とご登録の電話番号お願いできますか?<PERSON>です。電話番号は103、1ありがとうございます<PERSON>様ですね会員番号は手元にございますか会員番号はCSの2026のゼローゼロ60609ですあとメイランドです新しいのは県1で、<ORGANIZATION>、C、<PERSON>です消しましたでは新しいご住所をお願いします<LOCATION>のさあリンクピラーワンの数11回です。引っ越したのが先月の<DATE_TIME>でした。(以下略)

トランスフォーマー版(ja_core_news_trf)の検出結果

=== 検出されたエンティティ ===
ORGANIZATION     score=0.85  '鴨志賀モバイルカスタマースタッポート担当'
PERSON           score=0.85  '山田'
PERSON           score=0.85  '佐藤健一'
PERSON           score=0.85  '佐藤健一'
PERSON           score=0.85  'K・ニーナイ'
PERSON           score=0.85  'ハイフン佐藤S.Oアットマークエグザンプルコムです'
LOCATION         score=0.85  '東京都港区高輪2丁目21番1号'
DATE_TIME        score=0.85  '5月20日'
PERSON           score=0.85  '佐藤'
ORGANIZATION     score=0.85  'K.NICHI'
DATE_TIME        score=0.85  '15分'
ORGANIZATION     score=0.85  '佐藤アットマークリズアムトコムアテに'
ORGANIZATION     score=0.85  'サポッチェクス'

=== マスキング後 ===
お電話ありがとうございます。<ORGANIZATION>の<PERSON>と申します。本日はどういったご予定でしょうか。すいませんでしたので、登録住所の方をお願いしたいんですけど。こうかしこまりました。まず本人確認をさせてください。お名前とご登録の電話番号お願いできますか?<PERSON>です。電話番号は103、1ありがとうございます<PERSON>様ですね会員番号は手元にございますか会員番号はCSの2026のゼローゼロ60609ですあとメイランドです新しいのは県1で、<PERSON>、C、<PERSON>消しましたでは新しいご住所をお願いします<LOCATION>のさあリンクピラーワンの数11回です。引っ越したのが先月の<DATE_TIME>でした。ありがとうございますおそらについてですが今下4桁が1111111クレジットカードがクレジットカードがお金買い訳しちゃったんです新しいカードが411111111111111111111111111で有効期限が2020申しました本日のご請求は1万2800円となっております本日付で変更を反映いたしますあとマイナンバーの確認も必要って言われたんですけど123、4、5、678、9012であってますか?はい確認できましたそれでは<PERSON>様変更内の確認メールを<ORGANIZATION>で点<DATE_TIME>SATOで<ORGANIZATION>お送りいたしますご不明点があれば<ORGANIZATION>の10311111の2222までお願いいたします

答え合わせ

11項目のうち、文字起こし+Presidio でどれだけ拾えたかをまとめると次の通りです。

# 種類 台本中の値 文字起こし後の姿 lg trf 判定
1 人名(山田) 山田 山田 PERSON PERSON ✅ 検出
2 人名(佐藤健一) 佐藤健一 佐藤健一 PERSON PERSON ✅ 検出
3 組織名 カモシカモバイル 鴨志賀モバイル… PERSON(誤分類) ORGANIZATION △ trf のみ近い
4 電話番号(顧客) 03-1234-5678 103、1 なし なし ❌ 漏れ
5 電話番号(直通) 03-1111-2222 10311111の2222 なし なし ❌ 漏れ
6 会員番号 CS-2026-000609 CSの2026のゼローゼロ60609 なし なし ❌ 漏れ
7 メールアドレス kenichi-sato@example.com K・ニーナイ…アットマークリズアムトコム PERSON/ORG の残骸 同左 ❌ メールとしては漏れ
8 住所 東京都港区高輪2丁目21番1号 … (建物名以外は残る) LOCATION LOCATION △ 番地まで。建物名は漏れ
9 日付 5月20日 / 2028年3月 5月20日 / 2020 5月20日 のみ 同左 △ 一方のみ。さらに 15分 を誤検出
10 クレジットカード 4111-1111-1111-1111 41111…(桁増殖) なし なし ❌ Luhn 不成立で漏れ
11 マイナンバー 123456789012 123、4、5、678、9012 なし なし ❌ 漏れ

文字起こしの精度が良かったため、人名(#1 #2)や、住所の番地は(#8)はきれいに拾えてます。
一方、電話番号・会員番号・メール・カード番号・マイナンバーは、文字起こし段階で形が崩れたせいで軒並み漏れてしまいました。正規表現や Luhn チェックは「整った文字列」を前提にしているので、音で崩れた数字列には歯が立ちませんでした。

また拾えないだけでなく、ゴミも拾ってしまってます。15分(実際は読み上げの「点」由来)を DATE_TIME に、崩れたメール文字列を PERSON / ORGANIZATION に誤検出してしまうなど、表記崩れがノイズになっているケースもあります。

まとめ

文字起こしテキストの個人情報をマスクするにあたって、単純にフレームワークを使っただけでは、表記揺れ・音声認識の崩れが激しく、十分には消しきれないことが分かりました。より詳細にカスマイズしたり、コンテキストを活用したりすれば精度が上がりそうですが、それでも完全に除去するのは難しそうです。

今後精度向上しつつコストを抑えるのであれば、ローカルLLMなどを活用することも考えられますかね?
もし「文字起こし × 個人情報マスキング」でうまくやっている手法や知見があれば、ぜひコメントで教えていただきたいです。

参考リンク

7
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?