LLMの学習データを用意していた際、人名などの個人情報をマスキングしたくなった。
調べてみると、日本語の自然言語処理ライブラリであるGiNZAで固有表現を抽出できるみたい。
v5からTransformersモデルを採用しており、解析精度が向上したとのこと。
Transformersモデルによる解析精度の向上
GiNZA v5の解析精度は以前のバージョンから飛躍的な向上を遂げました。精度向上の主たる貢献はTransformers事前学習モデルの導入にあります。
高精度なGiNZAモデルを使って個人情報のマスキングに使えないか試してみた。
まずはインストール。
pip install -U ginza ja_ginza_electra
bashからでも使えるので、ちょっと試してみたい場合はPythonのコードを書かなくてすむ。
$ ginza
銀座でランチをご一緒しましょう。
# text = 銀座でランチをご一緒しましょう。
1 銀座 銀座 PROPN 名詞-固有名詞-地名-一般 _ 6 obl _ SpaceAfter=No|BunsetuBILabel=B|BunsetuPositionType=SEM_HEAD|NP_B|Reading=ギンザ|NE=B-GPE|ENE=B-City
2 で で ADP 助詞-格助詞 _ 1 case _ SpaceAfter=No|BunsetuBILabel=I|BunsetuPositionType=SYN_HEAD|Reading=デ
3 ランチ ランチ NOUN 名詞-普通名詞-一般 _ 6 obj _ SpaceAfter=No|BunsetuBILabel=B|BunsetuPositionType=SEM_HEAD|NP_B|Reading=ランチ
4 を を ADP 助詞-格助詞 _ 3 case _ SpaceAfter=No|BunsetuBILabel=I|BunsetuPositionType=SYN_HEAD|Reading=ヲ
5 ご ご NOUN 接頭辞 _ 6 compound _ SpaceAfter=No|BunsetuBILabel=B|BunsetuPositionType=CONT|Reading=ゴ
6 一緒 一緒 VERB 名詞-普通名詞-サ変可能 _ 0 root _ SpaceAfter=No|BunsetuBILabel=I|BunsetuPositionType=ROOT|Reading=イッショ
7 し する AUX 動詞-非自立可能 _ 6 aux _ SpaceAfter=No|BunsetuBILabel=I|BunsetuPositionType=SYN_HEAD|Inf=サ行変格,連用形-一般|Reading=シ
8 ましょう ます AUX 助動詞 _ 6 aux _ SpaceAfter=No|BunsetuBILabel=I|BunsetuPositionType=SYN_HEAD|Inf=助動詞-マス,意志推量形|Reading=マショウ
9 。 。 PUNCT 補助記号-句点 _ 6 punct _ SpaceAfter=No|BunsetuBILabel=I|BunsetuPositionType=CONT|Reading=。
以下は公式のサンプルコード。
import spacy
nlp = spacy.load('ja_ginza_electra')
doc = nlp('銀座でランチをご一緒しましょう。')
for sent in doc.sents:
for token in sent:
print(
token.i,
token.orth_,
token.lemma_,
token.norm_,
token.morph.get("Reading"),
token.pos_,
token.morph.get("Inflection"),
token.tag_,
token.dep_,
token.head.i,
)
print('EOS')
これをもとに、個人情報のマスキング用に書き換えたのがこちら。
import spacy
nlp = spacy.load("ja_ginza_electra")
def mask_text(text):
doc = nlp(str(text))
masked_text = ""
for sent in doc.sents:
for token in sent:
if token.pos_ == "PROPN":
masked_text += "[PROPN]"
else:
masked_text += token.text
return masked_text
[PROPN]は固有名詞(proper noun)を表す。
実際に使ってみるとこんな感じ。
text = "田中太郎さんは、1990年に東京都渋谷区で生まれました。神奈川県横浜市にあるABCカンパニーでエンジニアとして働いています。彼の電話番号は080-1234-5678です。メールアドレスはtanaka.taro@example.comです。"
mask_text(text)
[PROPN][PROPN]さんは、1990年に[PROPN]で生まれました。[PROPN]にあるABCカンパニーでエンジニアとして働いています。彼の電話番号は080-1234-5678です。メールアドレスはtanaka.taro@example.comです。
メールアドレスや電話番号は厳しいみたいなので、正規表現などによるルールベースマッチングが良さそう。
text = "田中太郎さんは東京の鈴木クリニックの山田花子先生からの紹介患者。セカンドオピニオンを求めてABC123大学病院を受診した。大阪のasdfカンパニーで勤務しており、仕事柄XYZ症候群になりやすいと考えられる。"
mask_text(text)
[PROPN][PROPN]さんは[PROPN]の[PROPN]クリニックの[PROPN][PROPN]先生からの紹介患者。セカンドオピニオンを求めてABC123大学病院を受診した。[PROPN]のasdfカンパニーで勤務しており、仕事柄XYZ症候群になりやすいと考えられる。
地名はマスキングできているが、病院名や企業名は微妙?
最終的には目視による確認が必要そうだが、全部手作業するよりは楽といったところか。
Transformersを使って学習を行う際、[PROPN]をトークナイザーのspecial tokenとして追加しても良いかもしれない。
もしくは空白や●●などに置き換えるケースもありそう。
備忘録としてコードを1行ずつ振り返っていく。
ginzaを使うためにはspacyが必要で、pip installした時に一緒に入ってくる。
まずはginzaをロードする。
公式サイトを見る限り、nlp = spacy.load(…)と書くのが一般的みたい。
import spacy
nlp = spacy.load("ja_ginza_electra")
次に、入力するテキストをトークンにする。
nlpからコールするとDocオブジェクトが帰ってくる。
A
Doc
is a sequence ofToken
objects. Access sentences and named entities, export annotations to numpy arrays, losslessly serialize to compressed binary strings. TheDoc
object holds an array ofTokenC
structs. The Python-levelToken
andSpan
objects are views of this array, i.e. they don’t own the data themselves.
doc = nlp(str(text))
Docの中は文単位で区切られており、Doc.sentsのイテレーターで1つ1つの文にアクセスできる。
for sent in doc.sents:
sentはTokenオブジェクトで構成されており、これまたイテレーターになっているのでループでアクセス可能。
for token in sent:
Tokenオブジェクトの属性には様々な情報が格納されており、トークナイズされる前のテキストはtext属性、品詞はposまたはpos_属性で取得できる。
text
Verbatim text content.
TYPE: strpos
Coarse-grained part-of-speech from the Universal POS tag set.
TYPE: intpos_
Coarse-grained part-of-speech from the Universal POS tag set.
TYPE: str
Universal POS tag setはここで確認できる。
POSはPart of Speechの略で品詞を表す。
今回は個人情報(=固有名詞)をマスキングしたかったので、PROPNに一致するトークンかどうかで場合分けすればOK。
今回はpos_属性でstringが一致するかどうかで書いてみた。
if token.pos_ == "PROPN":
masked_text += "[PROPN]"
else:
masked_text += token.text
もしintで比較したい場合、spacy.symbolsを使うとわかりやすい。
シンボルを使う場合、先程のコードは次のように書き換えられる。
アンダーバーの有無に注意。
from spacy.symbols import PROPN
(中略)
if token.pos == PROPN:
masked_text += "[PROPN]"
else:
masked_text += token.text
以上から出来上がったマスキング関数が冒頭に記載したものになる。
import spacy
nlp = spacy.load("ja_ginza_electra")
def mask_text(text):
doc = nlp(str(text))
masked_text = ""
for sent in doc.sents:
for token in sent:
if token.pos_ == "PROPN":
masked_text += "[PROPN]"
else:
masked_text += token.text
return masked_text
この関数を使う際は、テキストファイルをwith open(…)してからforループを回してもいいし、データフレームならpandasやpolarsでapplyしてもいい。
コメント