【python】wiktionaryから語義情報を抽出する

# 概要
wiktionaryから単語とその語義（に相当していそうな記述）を抽出しました。
具体的にはwiktionaryのマークアップ記述から語義に相当すると期待される数字箇条書きの領域を、その箇条書きが属する見出しの文字列とセットで取得しました。

```
word	head2	head3	meaning_id	meaning
青	[[漢字]]	意義	0	#[[グリーン]]。[[みどり]]。[[あお]]。[[くさき|草木]]の[[は|葉]]のよう...
青	{{ja}}	{{noun}}	0	#（複合語で）（あお）色の一つ、[[グリーン]]。[[くさき|草木]]の[[は|葉]]のような色。
青	{{zh}}	{{adjective}}	0	#{{おくりがな2|青|あお|い|あおい}}。[[緑]]の。[[グリーン]]の。
青	{{zh}}	{{adjective}}	1	#{{おくりがな2|黒|くろ|い|くろい}}。
青	{{zh}}	{{name}}	0	#（現代）[[w:青海省|青海省]]の略称。
```

なお、本記事では、箇条書き部分の抽出までを扱います。実際にアプリケーションで利用するには、抽出された文字列からのマークアップの削除や、誤抽出された文字列の削除を行う必要があります。これは今後の課題（機会があれば別記事で記述）とします。

# 方針
今回は、wiktionaryから単語の語義を抽出することが目的です。wiktionaryはwikipediaの辞典版のようなwebサイトであり、wikipediaと同じ文法のマークアップで記述されています。

wiktionaryでは、言語および品詞ごとに語義が記述されています。
例えば、[「馬」](https://ja.wiktionary.org/wiki/%E9%A6%AC)では、「日本語」「中国語」「朝鮮語」「ベトナム語」などのセクションが分かれています。また各セクションごとに「名詞」などの小セクションがあり、その中に数字箇条書きで語義が書かれています。
「馬」の記事テキストの一部を以下に示します。

```
=={{ja}}==
{{Wikipedia|ウマ}}
[[Category:{{ja}}|うま]]

===={{pron|jpn}}====
* 音読み :
** [[呉音]] : [[メ]]
** [[漢音]] : [[バ]]
** [[慣用音]] : [[マ]]
* 訓読み : [[うま]]、[[ま]]

==={{noun}}===
[[Category:{{ja}}_{{noun}}|うま]]
[[Category:日本語の基本語彙|うま]]
[[Category:{{ja}}_馬|*]]
#('''[[うま]]''') ウマ目（奇蹄目） ウマ科に属する動物の総称、特にウマ属に属するウマ。古くから[[家畜]]として飼われ、主に乗用や運搬、農耕などの使役用に用いられるほか、食用もされる。日本語では馬肉を[[桜肉]]とも称する。
#('''うま''')  木製または鋼鉄製の4本足の[[作業台]]。自動車整備時に車体を仮支えする時、大工仕事で切る木材を支えるなど、用途により多種存在する。
#('''うま''') [[将棋]]の[[竜馬]]の略称。
#('''マー''') [[シャンチー]]の[[こま|駒]]。[[チャトランガ]]のアシュワ（馬）に由来する。[[チェス]]の[[ナイト]]、[[将棋]]の[[桂馬]]に対応する。
#('''マ''') [[チャンギ]]の[[こま|駒]]。[[チャトランガ]]のアシュワ（馬）、シャンチーの馬に由来する。[[チェス]]の[[ナイト]]、[[将棋]]の[[桂馬]]に対応する。
```

複数の「=」で囲まれた文字列は見出しであり、囲みに使われる「=」の数が大きいほど、階層の深い見出しとなります。例外はありますが、言語の見出しは「==」、品詞の見出しは「===」が使われることが多いようです。

また語義も例外はありますが、基本的には「#」似続けて記述されることが多いようです。「#」は数字箇条書きを意味します。

語義を抽出するために、「#」に続く文字列を抽出するのは1つの手ですが、語義でないものも「#」に続けて書かれている可能性があり、ノイズが多くなりそうです。
解決策として、品詞の見出しに続くセクションで使われる数字箇条書きは語義である確率が高いです。よって、数字箇条書きを、それが属するセクションの見出しとセットで取得することで、のちの仕分けが簡単にできるようにします。

以上から、以下の方針で語義を抽出することにします。

+ 記事テキストを「==」で囲まれた見出し行で分割する。
+ 1で取得した分割のそれぞれを「===」で囲まれた見出し行で分割する。
+ 1で取得した分割のそれぞれから、「#」に続く文章を抽出し、1、2で取得した見出しの文字列とセットで保存する

なお語義情報をアプリケーション等で活用する上では以下の作業が合わせて必要と考えられますが、本記事では扱いません。

* 1、2で取得した見出しから言語、品詞でないものを除く（言語、品詞以外の領域で抽出された数字箇条書きは語義でない可能性が高い）。
* 抽出された文字列からマークアップの修飾子を除く
* 方針で抽出しきれなかった語義の把握（=例外的なスタイルで書かれた記事テキストの把握）

# 環境

```
% sw_vers
ProductName:    macOS
ProductVersion: 11.6.1
BuildVersion:   20G224
% python -V
Python 3.9.10
```

なお、pythonはlocalのjupyter notebookで実行しています。
またwiktionaryのxmlの取得日は2022/02/28ごろです。

# 実装
## wiktionaryのxmlの読み込み

wiktionaryのxmlをダウンロードし、解凍します。

```
curl -O https://dumps.wikimedia.org/jawiktionary/latest/jawiktionary-latest-pages-articles.xml.bz2
bzip2 -d jawiktionary-latest-pages-articles.xml.bz2
```

pythonでxmlを読み込みます。


```Python
# xmlの読み込み
import os
PATH = os.path.join("jawiktionary-latest-pages-articles.xml")
import xml.etree.ElementTree as ET
tree = ET.parse(path)
root = tree.getroot()
# 結果の表示
print("root.tag")
print(root.tag, "\n\n")
print("root.attrib")
print(root.attrib)
```

```
root.tag
{http://www.mediawiki.org/xml/export-0.10/}mediawiki

root.attrib
{'{http://www.w3.org/2001/XMLSchema-instance}schemaLocation': 'http://www.mediawiki.org/xml/export-0.10/ http://www.mediawiki.org/xml/export-0.10.xsd', 'version': '0.10', '{http://www.w3.org/XML/1998/namespace}lang': 'ja'}
```

単語をkeyと記事テキストをvalueとした辞書を作ります。
keyを指定するときに、root.tagの"mediawiki"の直前に書かれているURL（今回は`{http://www.mediawiki.org/xml/export-0.10/}`）をprefixとしてつける必要があることに注意します。

```Python
# prefixの取得
prefix = "{"+root.tag.split("{")[1].split("}")[0]+"}"
# titleとtextの取得
pages = root.findall("{}page".format(prefix))
dictionary_org = {}
for page in pages:
  title = page.find("{}title".format(prefix)).text
  text = page.find("{}revision".format(prefix)).find("{}text".format(prefix)).text
  assert title not in dictionary_org
  dictionary_org[title] = text
print(len(dictionary_org))
print(list(dictionary_org.items())[:5])
```

```
318932
[('MediaWiki:Mainpagetext', 'Wikiソフトウェアが正常にインストールされました。'), ('MediaWiki:Aboutpage', 'Wiktionary:ウィクショナリーへようこそ'), ('MediaWiki:Faqpage', 'Wiktionary:FAQ'), ('MediaWiki:Edithelppage', 'Help:編集の仕方'), ('MediaWiki:Wikipediapage', 'Wiktionaryページを表示')]
```

'MediaWiki:Mainpagetext'のようなkeyを除外します。今回はコロンを含んでいないもののみを残す、という条件にします。

```
290752
青
{{半保護S}}
==[[漢字]]==
<span lang="ja" xml:lang="ja" 

保護
{{WiktionaryPage}}
==日本語==
{{Wikipedia}}
{{ja-DEFA

靑
==[[漢字]]==
<span lang="ja" xml:lang="ja" style="fo

今日
{{ja-DEFAULTSORT|きょう}}
=={{L|ja}}==
{{ja-kanjitab|

故事成語
{{DEFAULTSORT:こしせいこ こじせいご}}
== {{ja}} ==
[[Categor

杞憂
{{DEFAULTSORT:きゆう}}
==日本語==
[[Category:日本語]]
===名詞

四面楚歌
{{DEFAULTSORT:しめんそか}}
=={{ja}}==
[[Category:{{ja}}
```

## 「==」の見出しで分割

正規表現で、「==」の見出しから次の同様の見出しが登場するまでの文字列を抽出します。実質的には「==」の見出し行で記事テキストを分割していることに近いです。

```Python
import re
re_head2_area = re.compile(r"(?<=\n)==\s*([^=]+?)\s*==(.+?)(?=$|\n==[^=]+?==)", re.DOTALL)

unknown = set()
dictionary_head2 = {}
for k,v in dictionary.items():
  result = re_head2_area.findall("\n"+v) # 先頭に"\n"を足すことで、抽出対象が最初にある場合も対応可能にする
  if not result:
    unknown.add(k)
  for h, area in result:
    dictionary_head2[(k,h)] = area

print("len(unknown)", len(unknown))
print("len(dictionary)", len(dictionary))
print("len(dictionary_head2)", len(dictionary_head2))
```

```
len(unknown) 5218
len(dictionary) 290752
len(dictionary_head2) 415554
```


len(unknown)は、re_head2_area.findallで何も取得されなかった、つまり、「==」の見出しが存在しなかった記事テキスト（単語）の数です。単語の例を以下に示します。

```Python
print(list(unknown)[:100])
```

```
['Agypten', '面梟', 'サム', '借方', 'Rockfish', 'أَزْرَق', 'えんどうまめ', 'perveke', 'wū', 'wasn’t', 'Aug.', '10度', 'ひこうじょう', 'Brytyjski', 'རི་པིན།', 'ぎのう', 'らいらく', 'fu2', '重増7度', '四つ子', 'пIераска', 'duzen (活用)', 'げひん', '磯馴松', 'Sudkoreio', 'ゲフ', 'ちゅうしゅうせつ', 'えぼしがい', 'ないあつ', 'Ghiaccio', 'refrigerators', 'すいさんぶつ', 'Dopełniacz', '唐梅', '差し出がましい', 'Grammaire', 'justice of the peace', '増8度', 'حَقِيقَةٌ', 'ハク', 'أَعْرَقَ', 'デレる', 'とらふずく', 'いりおもてやまねこ', 'Jajko', 'Mianownik', '炊き込みご飯', 'ぞうだい', 'ぎゅうにく', 'ホッ', 'ざり', 'どうれつ', 'siyám', 'يَوْمٌ', 'Déjà', 'Geben', 'Attorney General', 'こさくそうぎ', 'bić się', 'hú', '小獅子座', 'ベチ', 'もくやく', 'fāma', 'ju4', 'あおはたの', '穀潰し', 'ちごはやぶさ', 'ズチ', "Aren't", 'みつばち', 'hindio', '草葉の床', 'びふん', '類語', 'きんだいてき', 'trzymać się', 'ヱツ', 'Merkur(s)', 'ヘチ', 'wéi', 'Young', '長9度', 'Portugalski', 'podnieść się', 'Sabato', '水蛇座', 'أُسْرَةٌ', 'ぐらい', 'podnosić się', 'xi1', 'Jet', 'ふぞろい', '細工は流々仕上げを御覧じろ', 'かいりく', 'min3', 'ペーガソス', 'ばんめし', 'だいぎん', 'フン']
```

単語の中身を具体的に見てみると、同義の別単語の記事にリダイレクトさせる記事が多いようです。例としてunknown「炊き込みご飯」の記事テキストを以下に示します。

```Python
text = dictionary["炊き込みご飯"]
print(text)
```

```
#転送 [[炊き込み御飯]]
```

「炊き込みご飯」の語義は転送先の「炊き込み御飯」と同じなので、丁寧にやれば、抽出できそうですが、今は無視します。

len(dictionary)は記事の総数です。len(dictionary_head2)は「==」の見出しの総数です。一つの記事が複数の「==」の見出しを含むこともあるため、後者のほうが数が大きくなっています。

なお、ここでdictionary_head2は、(単語, 見出し)のタプルをkey、分割の記事テキストをvalueとした辞書です。

```Python
print(list(dictionary_head2.keys())[:10])
print(dictionary_head2[("青",r"{{ja}}")][:300])
```

```
[('青', '[[漢字]]'), ('青', '{{ja}}'), ('青', '{{zh}}'), ('青', '{{ko}}'), ('青', '{{vi}}'), ('青', 'コード等'), ('保護', '日本語'), ('保護', '{{L|zh}}'), ('保護', '{{L|ko}}'), ('保護', '{{L|vi}}')]

[[Category:{{ja}}|あお]]
==={{pron|ja}}===
* 音読み
** [[呉音]] : [[ショウ]]（シヤウ）
** [[漢音]] : [[セイ]]
** [[唐音]] : [[チン]]
* 訓読み
** 常用漢字表内
**: [[あお]]、[[あおい|あお-い]]
**名のり
**:きよ、はる
{{色|青|#0028E9}}

==={{noun}}===
{{wikipedia}}
[[Category:{{ja}} {{noun}}|あお]]
[[Category:{{ja}} 色名|あお]]
#（[[あお]]）[[色]]の一つ、[[ブルー]]。よく澄ん
```


## 「===」の見出しの抽出

「==」の場合とほぼ同様の手順で「===」の見出しの抽出（分割）を実施します。
分割は先程作成したdictionary_head2に対して行います。

```Python
import re
re_head3_area = re.compile(r"(?<=\n)===\s*([^=]+?)\s*===(.+?)(?=$|\n===[^=]+?===)", re.DOTALL)

unknown = set()
dictionary_head3 = {}
for (k1,k2),v in dictionary_head2.items():
  result = re_head3_area.findall("\n"+v)
  if not result:
    unknown.add((k1,k2))
  for h, area in result:
    dictionary_head3[(k1,k2,h)] = area

print("unknown", len(unknown))
print("len(dictionary_head2)", len(dictionary_head2))
print("len(dictionary_head3)", len(dictionary_head3))
```

```
unknown 36632
len(dictionary_head2) 415554
len(dictionary_head3) 613721
```

unknownは「===」の見出しがなかった分割の数です。それなりに数が多いですが、dictionary_head2の分割には、言語見出しでないものも含まれるので、本来抽出対象とすべきだができていない分割の数はさほど多くない可能性もあります。丁寧な仕事としては、unknownの分割に対して深堀りしたほうがよいですが、ここでは無視します。

len(dictionary_head2)、len(dictionary_head3)はそれぞれ「==」、「===」の見出しにおける分割の数です。一つの分割に複数の「===」が含まれることもあるので、後者のほうが数が多くなっています。

念の為、unknownおよびdicdtionary_head3のkey値の中身を見ておきます。

```Python
for i, v in enumerate(list(unknown)):
  if i > 5: break
  print(v)
print("--------------")
for i,(k,v) in enumerate(dictionary_head3.items()):
  if i > 5: break
  print(k)
```

```
('乳汁', '脚注')
('ꊃ', '規範彝文')
('ꂙ', '規範彝文')
('肓', 'コード等')
('ｻ', '半角カナ')
('႒', 'シャン文字')
--------------
('青', '[[漢字]]', '字源')
('青', '[[漢字]]', '意義')
('青', '{{ja}}', '{{pron|ja}}')
('青', '{{ja}}', '{{noun}}')
('青', '{{ja}}', '{{prov}}')
('青', '{{ja}}', '{{idiom}}')
```

## 語義の抽出

dictionary_head3として取得した「===」見出しによる分割から、語義（「#」で始まる行）を抽出します。


```Python
import re
re_numbering = re.compile(r"(?<=\n)#[^*#]+?(?=$|\n)")

unknown = set()
dictionary_meaning = {}
for (k1,k2,k3),v in dictionary_head3.items():
  result = re_numbering.findall("\n"+v)
  if not result:
    unknown.add((k1,k2,k3))
  else:
    dictionary_meaning[(k1,k2,k3)] = result

print("unknown", len(unknown))
print("len(dictionary_head3)", len(dictionary_head3))
print("len(dictionary_meaning)", len(dictionary_meaning))
```

```
unknown 243860
len(dictionary_head3) 613721
len(dictionary_meaning) 369861
```

unknownは語義が抽出できなかった分割の数です。半分よりは少ないですが、それなりに数が多いです。「===」の見出しには品詞以外のものもかなり含まれると考えられるので、仕方ない結果ではありますが、数が多いので、必要に応じて、本当にほぼ全てを妥当な結果としてよいかは別途検証したほうが良いかもしれません。本記事では例によって（一旦）無視します。

len(dictionary_head3)、len(dictionary_meaning)はそれぞれ「===」の見出しによる分割の数と、そのうち語義を抽出できた分割の数です（後者が語義の数でないことに注意）。1つの分割から複数の語義が抽出されることがありますが、今回は、それをまとめて1つのリストとしてvalueにしているので、数としては前者のほうが多くなっています。

unknownとdictionary_meaningの中身を見ておきます。

```Python
for i,v in enumerate(list(unknown)):
  if i > 5: break
  print(v)
print("--------------")
for i,(k,v) in enumerate(dictionary_meaning.items()):
  if i > 5: break
  print(k, v)
```

```
('kaun', '{{ceb}}', '{{etym}}')
('labor unions', '{{en}}', '異綴又は異形')
('vaartuig', '{{nl}}', '{{pron}}')
('baioneta', '{{ca}}', '{{etym}}')
('ಳ', '文字情報', '文字コード')
('longs', '{{en}}', '{{verb}}')
--------------
('青', '[[漢字]]', '意義') ['#[[グリーン]]。[[みどり]]。[[あお]]。[[くさき|草木]]の[[は|葉]]のような[[いろ|色]]。']
('青', '{{ja}}', '{{noun}}') ['#（複合語で）（あお）色の一つ、[[グリーン]]。[[くさき|草木]]の[[は|葉]]のような色。']
('青', '{{zh}}', '{{adjective}}') ['#{{おくりがな2|青|あお|い|あおい}}。[[緑]]の。[[グリーン]]の。', '#{{おくりがな2|黒|くろ|い|くろい}}。']
('青', '{{zh}}', '{{name}}') ['#（現代）[[w:青海省|青海省]]の略称。']
('青', '{{zh}}', '人名') ['#中国人の[[姓]]のひとつ']
('保護', '日本語', '名詞') ['#ある物が[[破壊]]されたりしないように[[まもる|守る]]こと。', '#社会的弱者や生活力の低い者に対し[[支援]]を行うこと。']
```

unknownはたしかに語義とは関係なさそうなら領域のように見えます。dictionary_meaningのほうも語義に相当する文字列が抽出できていそうです。

最後に分割数ではなく単語数でこれまでの抽出結果を眺めます。

```Python
# 語義を抽出できた単語等に関する統計情報を表示
print("len(dictionary_meaing)", len(set([k1 for k1,k2,k3 in dictionary_meaning])))
print("len(dictionary_head3)", len(set([k1 for k1,k2,k3 in dictionary_head3])))
print("len(dictionary_head2)", len(set([k1 for k1,k2 in dictionary_head2])))
print("len(dictionary)", len(dictionary))
```

```
len(dictionary_meaing) 259053
len(dictionary_head3) 284642
len(dictionary_head2) 285534
len(dictionary) 290752
```

単語数（記事数）で見ると、29万語のうち、26万語はなんらかの数字箇条書きを抽出できたようです。この箇条書きが本当に語義であるかは別途検証、調査の必要があります。
また残る3万語は今回の方法で語義を抽出できていません。これらがどういう理由で語義を抽出できなかったのか（語義を抽出できなくて構わないタイプの記事出会ったのか）は別途検証する必要があります。
これらは今後の課題とします。

## 結果の保存

最後に抽出結果をtsvに保存します。
まず結果をpandasのdataframeに格納します。
単語をword列に格納します。「==」「===」の見出しをそれぞれhead2、head3という列に格納し、word列と合わせて一意の分割を意味するようにします。各分割に対し複数の語義が存在している場合があるので、さらにmeaning_idという列を用意して、1行に1つの語義が入るような構造にします。

```Python
import pandas as pd
word, head2, head3, meaning_id, meaning = [],[],[],[],[]
for (k1,k2,k3), v in dictionary_meaning.items():
  for i,m in enumerate(v):
    word.append(k1)
    head2.append(k2)
    head3.append(k3)
    meaning_id.append(i)
    meaning.append(m)
df = pd.DataFrame({
  "word": word,
  "head2": head2,
  "head3": head3,
  "meaning_id": meaning_id,
  "meaning": meaning
})

print(df.head())
```

```
	word	head2	head3	meaning_id	meaning
0	青	[[漢字]]	意義	0	#[[グリーン]]。[[みどり]]。[[あお]]。[[くさき|草木]]の[[は|葉]]のよう...
1	青	{{ja}}	{{noun}}	0	#（複合語で）（あお）色の一つ、[[グリーン]]。[[くさき|草木]]の[[は|葉]]のような色。
2	青	{{zh}}	{{adjective}}	0	#{{おくりがな2|青|あお|い|あおい}}。[[緑]]の。[[グリーン]]の。
3	青	{{zh}}	{{adjective}}	1	#{{おくりがな2|黒|くろ|い|くろい}}。
4	青	{{zh}}	{{name}}	0	#（現代）[[w:青海省|青海省]]の略称。
```


保存します。

```Python
# 保存
df.to_csv("dictionary_meaning.tsv", index=False, sep="\t")
```

# おわりに
本記事では、wiktionaryの記事テキストから「#」に続く文字列として記述された語義を抽出する作業を行いました。ただし「#」に続く文字列という条件のみで抽出される文字列が、必ずしも語義であるとは限らないので、追加の分析作業がしやすいように、その文字列が属する見出し領域、具体的には「==」および「===」で囲まれた見出し文字列をセットで取得するようにしました。

今後の課題として以下があります。

* 「==」および「===」の見出し文字列に基づいて少なくとも語義とは考えにくい文字列を除く作業
    * 例えば「==」の見出しが「漢字」である領域は、漢字としての語義を特に必要としない場合は除いて良さそうです。あるいは日本語の語義のみが必要であれば、「{{ja}}」の見出しだけを抽出するなども考えられます。
* マークアップの除去
    * 抽出された語義には内部リンク等を示すためのマークアップが記述されているのでこれを除く必要があります。また語義の冒頭に「(あお)」等、よみがなが書かれている場合がありこれも不要な場合は除く必要があります。
* リダイレクト記事の語義取得
    * 「炊き込みご飯」が「炊き込み御飯」の記事にリダイレクトされているように、記事にリダイレクト先の単語のみが記載されており語義が抽出されない場合があります。この場合、リダイレクト先の単語の語義をリダイレクト元の単語の語義とすることが妥当と考え荒れるので、リダイレクト先の単語を取得し、適切な語義とセットにして取得する必要があります。
* 今回の方法で抽出できなかった記事の調査
    * 今回の方法で語義を抽出できなかった記事テキストが29万件のうち3万件ほどありましたので、これらの記事についてなぜ抽出できなかったのかを検討し、抽出が必要であれば別途プログラムを作るか手動で抽出するかといった判断をする必要があります。
    
 上述のとおり、語義を抽出できたというには課題が山積みの状況ですが、ここから先の処理は語義情報を活用するアプリケーションなどに依存する部分も大きくなるので、ここまでを区切りとしたく思います。
お読みいただきありがとうございました。