【python】wiktionaryから単語の語義を抽出する①

# 概要
wiktionaryから単語とその語義（に相当する記述）を抽出しました。
wiktionaryでは使用領域ごとの語義が分けて記載されていますが、今回は「日本語」における「名詞」または「成句」としての語義を抽出しました。
長くなったので記事を分ける予定です。
本記事では、背景、方針、事前準備、日本語見出しの抽出、までを扱っています。

# 背景
wiktionaryはwikipediaの辞典版のようなwebサイトで、単語と各使用領域における語義が書かれています。使用領域は、例えば[「馬」](https://ja.wiktionary.org/wiki/%E9%A6%AC)では、大きい使用領域として「漢字」「日本語」「中国語」「朝鮮語」「ベトナム語」「コード等」における語義が書かれています。また小さい使用領域として「日本語」では「名詞」としての語義が書かれています。馬は「名詞」のみですが、単語によっては複数の品詞におけるおける語義が併記されていることがあります。

今回は、wiktionaryの記事テキストに「日本語」の「名詞」における語義が書かれている場合、それに該当する部分を抽出することを目指します。

# 方針
wiktionaryの記事テキストはwikipediaと同じマークアップ言語で記述されています。「馬」の記事テキストの一部を抜粋します。

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

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

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

複数の=で囲まれた文字は見出しです。=が多いほど見出しの階層が深くなります。「日本語」の見出しは「=={{ja}}==」、「名詞」の見出しは「==={{noun}}===」と書かれていることがわかります。行頭の「#」は番号つき箇条書きです。語義は「#」に続けて書かれていることがわかります。

よって、大まかに以下のルールで語義を抽出することを目指します。

+ 記事テキストから「=={{ja}}==」以降で次の「==」見出しが登場するまでの領域を抽出する
+ 前記で抽出された領域から「==={{noun}}===」以降で次の「===」（またはそれより階層の深い）見出しが登場するまでの領域を抽出する
+ 前記で抽出された領域から「#」に続く文章を語義として抽出し、マークアップ等不要な情報を覗いたものを語義として取得する

ただし以下のことに注意します。

* 「日本語」見出しを表現するマークアップ言語は「=={{ja}}==」以外にもいくつかバリエーションがある
* 「名詞」見出しを表現するマークアップ記述は「==={{noun}}===」以外にもいくつかバリエーションがある

# 環境

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

なお、pythonはlocalのjupyter notebookで実行しています。
また実行日は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}}
```

以上で事前の読み込みは終わりです。

## 「日本語」見出しの抽出
まず「日本語」見出しに相当するマークアップ記述を記事テキストから抽出することを目指します。

### 「日本語」見出しのマークアップ記述のパターンの調査
背景の末尾で注意点として述べたように、「日本語」見出しの表現は何通りかあります。[Wiktionary:テンプレートの一覧/言語表記](https://ja.wiktionary.org/wiki/Wiktionary:%E3%83%86%E3%83%B3%E3%83%97%E3%83%AC%E3%83%BC%E3%83%88%E3%81%AE%E4%B8%80%E8%A6%A7/%E8%A8%80%E8%AA%9E%E8%A1%A8%E8%A8%98)によれば、{{ja}}と{{jpn}}の２種類あるようです。また前節の出力を見ると、「{{L|ja}}」「日本語」と書かれるケースも有るようです（前者の「L|」は言語（language）であることを示すprefixかなにかでしょうか？）。

網羅的に探すために、「==」で囲まれた文字列をすべて抽出して眺めてみます。

```Python
import re
re_tmp = re.compile(r"(?:^|\n)==\s*([^=].+?)\s*==")
head = {}
for k,v in dictionary.items():
  l = re_tmp.findall(v)
  for m in l:
    if m in head: continue
    head[m] = k

# 日本語っぽい文字列を含むものを出力

head = sorted(head.items(), key=lambda x:x[0])
print("見出し","記事")
for h,a in head:
  if "jp" in h or "ja" in h or "日本語" in h:
    print(h, a)
```

```
見出し 記事
[[:Category:{{ja}}|{{ja}}]] VIP
[[:Category:古代{{ja}}|古代{{ja}}]] ma
{{L|ajp}} طنجرة ولقت غطاها
{{L|cja}} sa
{{L|ja}} 今日
{{L|ojp}} いろ
{{T|ojp}} かゆ
{{ajp}} عرف
{{jaa}} kisi
{{jac}} hoh
{{jam}} bel
{{jao}} wabuda
{{jaz}} we
{{ja}} 青
{{jpn}} いずれにしても
{{ljp}} way
{{ojp}} は
{{ojp}}・漢文 未
古代日本語 ta
古典{{ja}} きたる
古典{{ja}}・漢文 如何
古典日本語 あゐ
日本語 保護
```

以下およびそれに"L|"または"T|"のprefixがついたものは、日本語ではなさそうなので無視します。

* {{jaz}}, {{ljp}}, {{ajp}}, {{cja}}, {{jaa}}, {{jac}}, {{jam}}, {{jao}}, 

以下を日本語に関係する見出しとして用います。なお、{{ojp}}は「古典日本語」（おそらくold japanese?）のようです。今回は古典日本語も抽出対象に含めることにします。

```
[[:Category:{{ja}}|{{ja}}]]
[[:Category:古代{{ja}}|古代{{ja}}]]
{{L|ja}}
{{L|ojp}}
{{T|ojp}}
{{ja}}
{{jpn}}
{{ojp}}
{{ojp}}・漢文
古代日本語
古典{{ja}}
古典{{ja}}・漢文
古典日本語
日本語
```

ちなみに、言語の見出しは必ず「==」であり、「===」（およびそれ以上の深さ）にはなっていないのでしょうか？ 念の為確認してみます。

まず、上記で調べた日本語見出しのいずれかにマッチする正規表現を作ります。

```Python
jp_lang_templates = r"""
[[:Category:{{ja}}|{{ja}}]]
[[:Category:古代{{ja}}|古代{{ja}}]]
{{L\|ja}}
{{L\|ojp}}
{{T\|ojp}}
{{ja}}
{{jpn}}
{{ojp}}
{{ojp}}・漢文
古代日本語
古典{{ja}}
古典{{ja}}・漢文
古典日本語
日本語
""".splitlines()[1:]

re_jp_lang = re.compile("(?:^|\n)==\s*({})\s*==".format("|".join(jp_lang_templates)))
print(re_jp_lang.findall(dictionary["機"]))
```

```
['{{ja}}']
```

次に、上記の日本語見出しが含まれておらず、「===」で囲まれた日本語関係の見出しが登場する記事テキストを抽出してみます。

```Python
# =が3つ以上の見出しにマッチする正規表現
re_tmp = re.compile(r"(?:^|\n)===\s*([^=].+?)\s*===")
head = []
jp1_cnt = 0 # ==の見出しを含む記事数をカウント
jp2_cnt = 0 # ===(およびそれより深い)見出しを含む記事数をカウント
for k,v in dictionary.items():
  # もし==の日本語見出しがあればスキップ
  if re_jp_lang.search(v):
    jp1_cnt += 1
    continue
  l = re_tmp.findall(v)
  for m in l:
    if "jp" in m or "ja" in m or "日本語" in m:
      jp2_cnt += 1
      head.append((m, k))

# 日本語っぽい文字列を含むものを出力

print("==の見出し数", jp1_cnt)
print("===の見出し数", jp2_cnt)
print("見出し","記事")
for h,a in head:
    print(h, a) 
```

```
==の見出し数 59948
===の見出し数 5
見出し 記事
{{pron|jpn}} ゟ
{{pron|jpn}} ヿ
{{pron|jpn}} 도합
古典日本語 らる
{{ja}} 一年草
```

5記事が検出されました。{{pron|jpn}}は発音(pronunciation）に関する見出しで、語義とは関係なさそうなので、今回は無視して良さそうです。古典日本語、{{ja}}は本来「==」で構成すべき見出しを間違って「===」にしてしまっているように思われます。これらを正しく検出するような正規表現、プログラムを書くことは可能と思われますが、数が少なく手動で抽出したほうが早いので、今回は無視します。

同様に、「==」も「===」もなく、いきなり「====」の言語見出しが登場している記事がないかも確かめておきます。

```Python
jp_lang_templates2 = r"""
{{pron\|jpn}}
古典日本語
{{ja}}
""".splitlines()[1:]
re_jp_lang2 = re.compile("(?:^|\n)===\s*({})\s*===".format("|".join(jp_lang_templates2)))
# =が4つの見出しにマッチする正規表現
re_tmp = re.compile(r"(?:^|\n)====\s*([^=].+?)\s*====")
head = []
jp1_cnt = 0 # ==の見出しを含む記事数をカウント
jp2_cnt = 0 # ===の見出しを含む記事数をカウント
jp3_cnt = 0 # ====の見出しを含む記事数をカウント
for k,v in dictionary.items():
  # もし==の日本語見出しがあればスキップ
  if re_jp_lang.search(v):
    jp1_cnt += 1
    continue
  # もし===の日本語見出しがあればスキップ
  if re_jp_lang2.search(v):
    jp2_cnt += 1
    continue

  l = re_tmp.findall(v)
  for m in l:
    if "jp" in m or "ja" in m or "日本語" in m:
      jp3_cnt += 1
      head.append((m, k))

# 日本語っぽい文字列を含むものを出力

print("==の見出し数", jp1_cnt)
print("===の見出し数", jp2_cnt)
print("====の見出し数", jp3_cnt)
print("見出し","記事")
for h,a in head:
    print(h, a)  
```

```
==の見出し数 59948
===の見出し数 5
====の見出し数 11
見出し 記事
{{pron|ja}} 睪
{{pron|ja}} MIGA
{{pron|ja}} ICSID
{{pron|ja}} UNIDO
{{pron|ja}} ESCAP
{{pron|ja}} SOHO
{{pron|ja}} CEPA
{{pron|jpn}} 龣
{{pron|jpn}} 江州
{{pron|ja}} ぬける
{{pron|jpn}} クッパ
```

11記事が該当しましたが、全て発音に関するものなので、無視してよさそうです。
またほぼ同様の調査を「=====」に対してしたところ該当する記事は0件でした。

以上から、ごく少数の取りこぼしには目をつむりつつ、「==」の階層に対して日本語の見出しをチェックする方針で問題なさそうなことがわかりました。

### 「日本語」見出しのエリアの抽出
 最後に「日本語」見出しから、次の同階層（「==」）見出しまでの区間を抽出する関数を定義します。
 
 ```
jp_lang_templates = r"""
\[\[:Category:{{ja}}\|{{ja}}\]\]
\[\[:Category:古代{{ja}}\|古代{{ja}}\]\]
{{L\|ja}}
{{L\|ojp}}
{{T\|ojp}}
{{ja}}
{{jpn}}
{{ojp}}
{{ojp}}・漢文
古代日本語
古典{{ja}}
古典{{ja}}・漢文
古典日本語
日本語
""".splitlines()[1:]

re_jp_area = re.compile("(?<=\n)(==\s*(?:{})\s*==.+?)(?=$|\n==[^=]+?==)".format("|".join(jp_lang_templates)), re.DOTALL)

def get_jp_area(text):
  return re_jp_area.findall("\n"+text)

# 日本語のセクションが末尾まで続かない場合
text = dictionary["馬"]
result = get_jp_area(text)
print(len(result))
#print(result)
print("略") 
# 日本語のセクションが末尾まで続かない場合
text = dictionary["は"]
result = get_jp_area(text)
print(len(result))
#print(result)
print("略")
 ```
 
 ```
 1
["==[[:Category:{{ja}}|{{ja}}]]==\n[[Category:{{ja}}|いたこ]]\n\n==={{noun}}===\n[[Category:{{ja}} {{noun}}|いたこ]]\n\n# （東北地方で）[[みこ|巫女]]（歴史的に多くは目の不自由な女性だが、女性とは限らない）。[[シャーマン]]。\n:亡くなった人の霊を口寄せするのが普通。 cf. {{ain}}: [[itako]]\n\n===={{pron|ja}}====\n;い↗たこ\n:{{IPA1|(ʔ)itákó}}\n:{{X-SAMPA|(?)ita_Hko_H}}\n\n===={{ref}}====\n* （青森県下北半島）恐山（おそれざん）の'''イタコ'''。\n\n===={{etym}}====\n{{ain}}の [[itako]] と同源。\n\n===={{trans}}====\n*{{en}}: [[shaman]]\n\n===={{rel}}====\n* 市子・神子・巫子（[[いちこ]]）（イタコのイにタが[[前進的母音同化]]したか、タがコ(ö)に[[母音調和]]したもの）\n* 斎く（[[いつく]]）、斎（[[いつき]]）、斎槻（[[いつき]]）\n* 美し（[[いつくし]]）、厳し（[[いつくし]]）、慈しぶ（[[いつくしぶ]]）\n*{{syn}}: 宣り・告り・祝り・罵り（[[のり]]）、祝ひ（[[いはひ]]）"]
1
略
2
略
```

日本語のセクションが末尾まで続く場合、続かない場合、複数のareaが検出される場合について、正しく検出できていそうです。

記事ごとのareaの検出数をチェックします。

```Python
# get_jp_areaで検出されるarea数ごとに記事を分ける
obj = {}
for k,v in dictionary.items():
  result = get_jp_area(v)
  if result:
    if len(result) not in obj:
      obj[len(result)] = []
    obj[len(result)].append(k)
print(obj.keys())
print(list(map(len, obj.values())))
print(sum(list(map(len, obj.values()))))
```

```
dict_keys([1, 2])
[58971, 977]
59948
```

areaが1個抽出される記事と2個抽出される記事があるようです。2個検出される記事は「日本語」と「古典日本語」の2つが検出されていると考えられます。また、1つ以上のareaが検出された記事の総数（59948）は、これまでの結果と一貫しているので、正規表現の間違いもなさそうです。

今後に備えて、抽出されたエリアが、日本語と古典日本語のどちらであるかを合わせて出力できるように関数を改良します。

```Python
lang_table = r"""
\[\[:Category:{{ja}}\|{{ja}}\]\] 日本語
\[\[:Category:古代{{ja}}\|古代{{ja}}\]\] 古典日本語
{{L\|ja}} 日本語
{{L\|ojp}} 古典日本語
{{T\|ojp}} 古典日本語
{{ja}} 日本語
{{jpn}} 日本語
{{ojp}} 古典日本語
{{ojp}}・漢文 古典日本語
古代日本語 古典日本語
古典{{ja}} 古典日本語
古典{{ja}}・漢文 古典日本語
古典日本語 古典日本語
日本語 日本語
""".splitlines()[1:]
lang_table = [v.replace("\\","").split(" ") for v in lang_table]
lang_table = {k:v for k,v in lang_table}
#print(lang_table)

re_area = re.compile("(?<=\n)==\s*({})\s*==(.+?)(?=$|\n==[^=]+?==)".format("|".join(list(map(re.escape,lang_table)))), re.DOTALL)

def get_area(text):
  result = re_area.findall("\n"+text)
  #print(result)
  result = [(lang_table[h],a) for h,a in result]
  return result


# 日本語のセクションが末尾まで続く場合
text = dictionary["いたこ"]
result = get_area(text)
print([v[0] for v in result])
print(result)
# 日本語のセクションが末尾まで続かない場合
text = dictionary["馬"]
result = get_area(text)
print([v[0] for v in result])
#print(result)
print("略") 
# 日本語のセクションが末尾まで続かない場合
text = dictionary["は"]
result = get_area(text)
print([v[0] for v in result])
#print(result)
print("略")
```

```
['日本語']
[('日本語', "\n[[Category:{{ja}}|いたこ]]\n\n==={{noun}}===\n[[Category:{{ja}} {{noun}}|いたこ]]\n\n# （東北地方で）[[みこ|巫女]]（歴史的に多くは目の不自由な女性だが、女性とは限らない）。[[シャーマン]]。\n:亡くなった人の霊を口寄せするのが普通。 cf. {{ain}}: [[itako]]\n\n===={{pron|ja}}====\n;い↗たこ\n:{{IPA1|(ʔ)itákó}}\n:{{X-SAMPA|(?)ita_Hko_H}}\n\n===={{ref}}====\n* （青森県下北半島）恐山（おそれざん）の'''イタコ'''。\n\n===={{etym}}====\n{{ain}}の [[itako]] と同源。\n\n===={{trans}}====\n*{{en}}: [[shaman]]\n\n===={{rel}}====\n* 市子・神子・巫子（[[いちこ]]）（イタコのイにタが[[前進的母音同化]]したか、タがコ(ö)に[[母音調和]]したもの）\n* 斎く（[[いつく]]）、斎（[[いつき]]）、斎槻（[[いつき]]）\n* 美し（[[いつくし]]）、厳し（[[いつくし]]）、慈しぶ（[[いつくしぶ]]）\n*{{syn}}: 宣り・告り・祝り・罵り（[[のり]]）、祝ひ（[[いはひ]]）")]
['日本語']
略
['日本語', '古典日本語']
略
```

各記事に含まれる使用領域を抽出して、抽出できた記事の個数をカウントします。
ついでに抽出できた使用領域の組み合わせも出力しておきます。

```Python
# get_jp_areaで検出されるarea数ごとに記事を分ける
obj = {} #記事に含まれるエリアの種類数と該当記事数の辞書
area_kinds = set() # 記事に含まれるareaの組み合わせを取得
for k,v in dictionary.items():
  result = get_area(v)
  if result:
    if len(result) not in obj:
      obj[len(result)] = []
    obj[len(result)].append(k)
    area_kinds.add(tuple([v[0] for v in result]))
print(obj.keys())
print(list(map(len, obj.values())))
print(sum(list(map(len, obj.values()))))
print(area_kinds)
```

```
dict_keys([1, 2])
[58971, 977]
59948
{('古典日本語',), ('日本語',), ('古典日本語', '日本語'), ('日本語', '古典日本語'), ('日本語', '日本語')}
```

抽出できた記事の数は、これまでと一貫しているのでよさそうです。
抽出された使用領域については('日本語', '日本語')というのが気になります。全く同じ見出しが2回登場している可能性があります。
どの記事か見てみます。

```Python
# 日本語のエリアを2つ含む記事タイトルを取得

for k,v in dictionary.items():
  result = get_area(v)
  if not result: continue
  areas = [v[0] for v in result]
  if areas == ["日本語", "日本語"]:
    print(k)
```

```
事務局
```

「[事務局](https://ja.wiktionary.org/wiki/%E4%BA%8B%E5%8B%99%E5%B1%80)」という記事でした。リンク先を見てみると、変な位置に「日本語」の見出しがあるので、意図的というよりはミスっぽいです。必要に応じて手動で語義を抽出すればよいので、ここでは無視します。

途中経過の記録として、get_area関数でテキストが抽出できた記事のみについて、抽出部分と合わせて、jsonに保存しておきます。

```Python
dictionary_jp = {}
for k,v in dictionary.items():
  area = get_area(v)
  if not area: continue
  dictionary_jp[k] = {}
  for a, t in area:
    dictionary_jp[k][a]=t

# 抽出情報の出力
print("len", len(dictionary_jp))
for i, (k,v) in enumerate(dicctionary_jp.items()):
  print(k, v.keys())
  for k2,v2 in v.items():
    print(k2)
    print(v2[:50])
  print("======")
  
with open("dictionary_jp.json","w") as f:
  json.dump(dictionary_jp, f, indent=2, ensure_ascii=False)
```

# おわりに
このあと「名詞」見出しの抽出と、語義の抽出をする必要があるのですが、長くなりすぎたので、記事を分けたいと思います。