PythonでWikipediaの情報を取得する

仕事で諸事情あり、Wikipediaの情報を取得したいことがあったのでその方法を紹介します。
最初はrequestsなどのHTTPクライアントでデータを取ってきて頑張ってHTMLをパースしようかと思っていたのですが、実はPythonでWikipediaを利用するには専用のライブラリが公開されています。それがこちらの wikipedia です。 PyWikipediaとかじゃなくて そのままの名前なのですね。
参考: PyPIのページ wikipedia · PyPI
ドキュメント Wikipedia — wikipedia 0.9 documentation

どうやら、Wikipedia本体がそのそも、Media Wiki API というAPIを公開してくれていて、それをラップしているようです。

APIにはかなり多様な機能が実装されているようですが、いったん僕の用途としては用語の検索と、該当ページのコンテンツの取得の二つができれば良いのでその点に絞って使い方を紹介していきます。

一番シンプルな使い方は Quickstart ページを見るのが良いでしょう。
とりあえず、適当な単語で検索してみます。僕たちは日本語を使うので、最初に言語を設定します。(設定しない場合はデフォルトの言語は英語で、日本語の単語を検索したとしても英語版Wikipedia内で検索されます。)

import wikipedia


wikipedia.set_lang("ja")
print(wikipedia.search("データ分析"))
"""
['データ解析',
 'データ',
 '慶應義塾大学パネルデータ設計・解析センター',
 'データベース',
 'データ・クラスタリング',
 'ビッグデータ',
 '主成分分析',
 '分散分析',
 'NTTデータ',
 '精神分析学']
"""

言語を設定した後、「データ分析」で検索してマッチしたページのタイトルの一覧が取得できましたね。(実際のWikipediaで検索すると、一番目の候補のデータ解析のページにリダイレクトされます。)

set_lang() で設定できる略号(上の例で言えばja)と言語(上の例では日本語)の対応は、languages()ってメソッドで一覧が取れます

print(wikipedia.languages())
"""
{'aa': 'Qafár af',
 'ab': 'аԥсшәа',
 'abs': 'bahasa ambon',
 'ace': 'Acèh',
 'ady': 'адыгабзэ',
 'ady-cyrl': 'адыгабзэ',
 'aeb': 'تونسي/Tûnsî',
 'aeb-arab': 'تونسي',
 # 以下略
"""

さて、先ほどのsearchの結果、「データ解析」ってページがあることがわかったので具体的にそのページのコンテンツを取得してみましょう。それには、page というメソッドを使います。結果は、WikipediaPageというオブジェクトで取得でき、タイトルや中身をその属性として持ちます。とりあえず、タイトルとサマリーを表示してみます。

wp = wikipedia.page("データ解析")

print(wp.title)
# データ解析

print(wp.summary)
"""
データ解析(データかいせき、英: data analysis)は、データ分析(データぶんせき)とも呼ばれ、
有用な情報を発見し、結論を報告し、意思決定を支援することを目的として、データを検査し、
クリーニングや変換を経て、モデル化する一連のプロセスである。データ解析には多数の側面とアプローチがあり、
色々な名称のもとで多様な手法を包含し、ビジネス、科学、社会科学のさまざまな領域で用いられている。
今日のビジネス界において、データ解析は、より科学的な意思決定を行い、ビジネスの効率的な運営に貢献する役割を担っている。
データマイニングは、(純粋な記述的な目的ではなく)予測的な目的で統計的モデリングと
知識獲得に重点を置いた固有のデータ解析技術である。
これに対し、ビジネスインテリジェンスは、主にビジネス情報に重点を置いて、集計に大きく依存するデータ解析を対象としている。
統計学的な用途では、データ解析は記述統計学 (en:英語版) 、探査的データ解析(EDA)、確認的データ解析(仮説検定)(CDA)に分けられる。
EDAはデータの新たな特徴を発見することに重点を置き、CDAは既存の仮説の確認または反証に焦点を当てる。
予測分析は、予測的な発生予報あるいは分類のための統計モデルの応用に重点を置き、
テキスト分析は、統計的、言語的、および構造的な手法を用いて、非構造化データの一種であるテキストデータから情報を抽出し知識の発見や分類を行う。
上記はどれも、データ解析の一種である。
データ統合はデータ解析の前段階であり、データ可視化およびデータ配布はデータ解析と密接に関連している。
"""

ページのコンテンツ全体は、content プロパティに持っていて、またhtml()メソッドを使うと、そのページのHTML全体を取得することもできます。

wp.content  # コンテンツ全体
wp.html()  # HTMLが取得できる

どちらも結果が非常に大きいので出力は省略します。

このほか、そのページのurl (wp.url)やリンクされている他の単語の一覧(wp.links)などの属性も持っています。どんな属性があるのかの一覧はドキュメントの、class wikipedia.WikipediaPage の部分を見ていただくのが良いと思います。

上記では、まず、wikipedia.page() で ページオブジェクトを取得して、その後そこからsummaryを取得しましたが、実は summaryを直接取得することもできます。それは単に、 wikipedia.summary()を使うだけです。(contentにはこれはないんですよね。summary専用の機能と考えて良さそうです。)

# summaryは直接取得できる
print(wikipedia.summary("データ解析"))
"""
データ解析(データかいせき、英: data analysis)は、 # 長いので以下略
"""

# 実はデータ分析、で検索しても勝手にリダイレクトしてデータ解析の結果を返してくれる
print(wikipedia.summary("データ分析"))
"""
データ解析(データかいせき、英: data analysis)は、 # 長いので以下略
"""

これで非常に簡単にWikipediaのデータが使えるようになりました。
ただ、一点気をつけないといけないことがあります。それは、検索すると曖昧さ回避のページに飛ぶような単語で検索する場合です。例えば、地名、人名、企業名、等々で使われる「豊田」でやってみると、DisambiguationError という例外が発生します。ライブラリが例外オブジェクトを作ってくれているのでそれでキャッチできます。

try: 
    wp = wikipedia.page("豊田")
except wikipedia.exceptions.DisambiguationError as e:
    print(e)
    
"""
"豊田" may refer to: 
豊田市
豊田町 (曖昧さ回避)
豊田町 (山口県)
豊田町 (静岡県)
豊田村 (曖昧さ回避)
豊田郡 (曖昧さ回避)
豊田郡
豊田 (名古屋市)
豊田地区
豊田 (日野市)
豊田 (紀の川市)
豊田鎮 (通遼市)
豊田鎮 (南靖県)
豊田郷 (彰武県)
豊田郷 (新寧県)
フォンディエン県 (トゥアティエン=フエ省)
フォンディエン県 (カントー)
豊田駅
豊田駅 (北海道)
豊田駅 (花蓮県)
新豊田駅
新豊田駅
三河豊田駅
豊田市駅
上豊田駅
肥後豊田駅
豊田町駅
豊田本町駅
トヨタグループ
トヨタ自動車
豊田自動織機
トヨタ紡織
豊田通商
豊田中央研究所
豊田合成
豊田鉄工
豊田スタジアム
豊田 (飲食業)
豊田エリー
豊田清
豊田順子
豊田孝治
豊田真由子
豊田萌絵
豊田泰光
豊田穣
豊田陽平
豊田ルナ
豊田佐吉
豊田喜一郎
豊田英二
豊田章男
豊田インターチェンジ
豊田市#道路
豊田 (小惑星)
豊田館跡
豊田ナンバー
豊田工業大学
^
「豊田」で始まるページの一覧
"""

用途が多様な単語で使う場合は気をつけるようにしましょう。




想定通りの結果になりましたね。

これで、gensimのphrasesモデルでフレーズ抽出に使われているスコアの計算式が理解できました。

どういうスコアなのかが分かればそれをもとに閾値を適切に決めれるのでは、という期待があったのですが、正直、この計算式だからこうだみたいな目安はまだあまり見えてきませんでした。

一回 min_countと閾値を両方ともものすごく低い値にして学習し、スコアの分布を見たり、だいたい何単語くらい抽出したいのかといったことをかがえて何パターンか試して使っていくのが良いのかなと思います。

gensimでフレーズ抽出

以前このブログで、テキストデータ中のよく連続する単語を検出するコードを紹介しました。
参考: Pythonを使ってよく連続する文字列を検索する

これは単純にある単語の前か後に出現しやすい単語を探すだけのコードだったのですが、実は同じような目的のモデルでもう少しスマートなロジックで実装されたものがgensimにあることがわかったのでそれを紹介します。

なお、今回の記事は以下のバージョンのgensimで動かすことを前提とします。

$ pip freeze | grep gensim
gensim==4.1.2

僕は複数開発環境を持っているのですが、gensim==3.8.0 など、3系の環境と、今使っている4系の環境で細かい挙動が色々異なり少し手こずりました。(会社のMacで動いたコードが私物のMacで動きませんでした。)
今回紹介するモデルに限らず、githubのgensimのリポジトリのWikiにマイグレーションガイドが出てるので、gensimを頻繁に使われる方は一読をお勧めします。
参考: Migrating from Gensim 3.x to 4 · RaRe-Technologies/gensim Wiki · GitHub

前置きが長くなりました。今回紹介するのは、gensimのphrasesです。
ドキュメント: models.phrases – Phrase (collocation) detection — gensim

要は、分かち書き済みの文章から学習して、頻繁に連続する2単語をフレーズとして抽出してくれるモデルです。
「頻繁に連続する」の基準として、僕が以前の記事で紹介したような単純な割合ではなく、論文で提唱されている手法(を元にした関数)を使ってスコアリングし、そのスコアが閾値を超えたらフレーズとして判定するという手法が採られています。(デフォルトで使われるのは1個目の方です。2個目はオプションで使うことができます。)
参考:
– Distributed Representations of Words and Phrases and their Compositionality
– Normalized (Pointwise) Mutual Information in Collocation Extraction” by Gerlof Bouma

今回の記事は使い方をメインで扱いたいので、このスコアリング関数については次の記事で紹介しましょうかね。

早速使っていきましょう。まず学習させるデータの準備です。以前用意したライブドアニュースコーパスを使います。今回はお試しで、そんなたくさんのデータ量いらないので、「ITライフハック」のデータだけ使います。
参考: livedoorニュースコーパスのファイルをデータフレームにまとめる

上記の記事で作ったCSVデータの読み込みと、分かち書きまでやっておきます。

import subprocess
import pandas as pd
import MeCab


# データの読み込み
df = pd.read_csv("./livedoor_news_corpus.csv")
# 今回は"it-life-hack" だけ使う
df = df[df.category=="it-life-hack"].reset_index(drop=True)
# ユニコード正規化とアルファベットの小文字統一
df.text = df.text.str.normalize("NFKC").str.lower()

# 辞書のパス取得
dicdir = subprocess.run(["mecab-config", "--dicdir"], capture_output=True, text=True).stdout.strip()
# 今回は品詞情報も原型変換も行わないので -Owakati で実行する。
tagger = MeCab.Tagger(f"-Owakati -d {dicdir}/ipadic")


# 分かち書きした結果を配列で返す関数
def mecab_tokenizer(text):
    return tagger.parse(text).split()


# 動作確認
print(mecab_tokenizer("すもももももももものうち"))
# ['すもも', 'も', 'もも', 'も', 'もも', 'の', 'うち']

# 分かち書き
df["tokens"] = df.text.apply(mecab_tokenizer)

これで、各テキストを分かち書きして配列にしたものがdf[“tokens”]に入りました。 (scikit-learnの場合は空白区切りの文字列にしますが、gensimの場合は単語を要素とする配列でデータを用意します。)
早速Phrasesモデルを作ります。

デフォルトのスコア関数、閾値はかなり大きめの1000で学習してみます。(あまりたくさんフレーズを見つけられても、この記事ではどうせ紹介できないのでかなり絞っています。デフォルトは10なので、通常の利用では1000は大きすぎです。)

from gensim.models.phrases import Phrases


phrase_model = Phrases(
    sentences=df["tokens"],  # 学習するデータ
    min_count=20,  # 最低何回出現した単語および単語ペアを対象とするか。デフォルト5
    scoring='default',  # スコアリングに用いる関数。 "default", "npmi", もしくは自作の関数を指定。
    threshold=1000,  # スコアが何点を超えたらフレーズとみなすか。でフォルト10.0
)

さて、これで学習ができました。学習した語彙は vocab プロパティが持っています。

phrase_model.vocab
"""
{'マイクロンジャパン': 2,
 'は': 12486,
 'マイクロンジャパン_は': 1,
 '、': 21839,
 'は_、': 3765,
 '従来': 138,
 '、_従来': 54,
 'の': 25248,
 '従来_の': 68,
# 以下略
"""

単語とその単語の出現回数に加えて、アンダーバーで二つの単語を繋いだbi-gram について、その出現回数の辞書となっています。(4系のgensimではvocabが単純な辞書ですが、実は3系では違ったのですよ。gensimオリジナルの型でしたし、単語はエンコーディングされていました。)

このモデルが結果的に見つけてくれたフレーズは、export_phrases()メソッドで取得することができます。(これも3系4系で挙動が違うメソッドです。)

phrase_model.export_phrases()
"""
{'ガ_ジェット': 1793.2403746097816,
 'インター_フェイス': 1398.7857142857142,
 '池田_利夫': 1409.4339100346021,
 '岡本_奈知': 1444.5520523497917,
 'ジャム_ハウス': 1377.0331304935767,
 'エヌプラス_copyright': 1409.4339100346021,
 'all_rights': 1259.6684809500248,
 'rights_reserved': 1393.045143638851,
 '上倉_賢': 1367.7015873015873,
 'キン_ドル': 1062.89667445223,
# 以下略
"""

見つけたフレーズと、そのフレーズのスコアの辞書として結果が得られます。

あの単語と、この単語の組み合わせのスコアって何点だったのかな?と思ったら、scoringメソッドで調べられます。気になるフレーズが検出されなかったら見てみましょう。

引数は結構たくさん渡す必要あります。まずヘルプ見てみましょう。

phrase_model.scoring?
"""
Signature:
phrase_model.scoring(
    worda_count,
    wordb_count,
    bigram_count,
    len_vocab,
    min_count,
    corpus_word_count,
)
"""

試しに、「キン_ドル」で1062.89… であることを見ておきましょうかね。worda_count とかは先に述べた通り、vocabから拾ってこれます。コーパスの単語数頭の情報はモデルが持ってるのでそこからとりましょう。

phrase_model.scoring(
    phrase_model.vocab["キン"],
    phrase_model.vocab["ドル"],
    phrase_model.vocab["キン_ドル"],
    len(phrase_model.vocab),
    phrase_model.min_count,
    phrase_model.corpus_word_count
)
# 1062.89667445223

学習に使ったデータとは別のテキストから、学習済みのフレーズを検索することもできます。

sample_data = [
    ['アマゾン', 'の', '新しい', 'ガ', 'ジェット'],
    ['新しい', 'キン', 'ドル', 'を', '買い', 'まし', 'た'],
]

print(phrase_model.find_phrases(sample_data))
# {'ガ_ジェット': 1793.2403746097816, 'キン_ドル': 1062.89667445223}

また、次のようにdictのようにモデルを使うと、渡されたデータ内で見つけたフレーズを _ で連結してくれます。結果がジェネレーターで帰ってくるので、listを使って配列にしてからprintしました。これはとても便利な機能なのですが、このような辞書的な呼び出し方ではなく、transformか何か名前のあるメソッドにしてほしかったですね。

print(list(phrase_model[sample_data]))
# [['アマゾン', 'の', '新しい', 'ガ_ジェット'], ['新しい', 'キン_ドル', 'を', '買い', 'まし', 'た']]

ちなみに、 _ だと不都合がある場合は、モデル学習時に delimiter 引数で違う文字を使うこともできます。

スコア関数を変えたり、閾値を変えたらり、また、スコア関数の中でmin_countなども使われていますので、この辺の値を変えることで結果は大きく変わります。なかなか面白いので色々試してみましょう。

また、このモデルを重ねがけするように使うことで、3単語以上からなるフレーズを抽出することもできます。(閾値などの調整に少々コツが必要そうですが。)
そのような応用もあるので、なかなか面白いモデルだと思います。

scikit-learnでテキストをBoWやtfidfに変換する時に空白以外の場所で単語を区切らないようにする

以前、scikit-learnのテキスト系の前処理モデルである、CountVectorizer
TfidfVectorizer において、1文字の単語を学習結果に含める方法の記事を書きました。
参照: scikit-learnでテキストをBoWやtfidfに変換する時に一文字の単語も学習対象に含める

この記事の最後の方で、以下のようなことを書いてました。

これ以外にも “-” (ハイフン) などが単語の境界として設定されていて想定外のところで切られたり、デフォルトでアルファベットを小文字に統一する設定になっていたり(lowercase=True)と、注意する時に気をつけないといけないことが、結構あります。

このうち、ハイフンなどの空白以外のところで単語を切ってしまう問題はワードクラウドで可視化したり単語の出現頻度変化を調べたりする用途の時に非常に厄介に思っていました。機械学習の特徴量を作る時などは最終的な精度にあまり影響しないことが多いので良いのですが。

これを回避するスマートな方法を探していたのですが、それがようやく分かったので紹介します。

前の記事でも行ったように、token_patternの指定で対応を目指したわけですが、以下のコード中の\\b の部分をどう調整したら良いのかがわからず苦戦していました。
このbは、\\wと\\Wの境にマッチするという非常に特殊な正規表現ですが、これをどうすれば空白と\\wの間にマッチさせらるのかが分からなかったのです。

# 1文字の単語も学習する設定
bow_model = CountVectorizer(token_pattern='(?u)\b\w+\b')

それがあるとき気づいたのですが、この\\b とついでに(?u)は無くても動作変わらないんですよ。

先にそれを紹介しておきます。以前作ったニュースコーパスのデータでやってみます。
参考: livedoorニュースコーパスのファイルをデータフレームにまとめる

import MeCab
import subprocess
import pandas as pd
from sklearn.feature_extraction.text import CountVectorizer

# データの読み込み
df = pd.read_csv("./livedoor_news_corpus.csv")
# ユニコード正規化とアルファベットの小文字統一
df.text = df.text.str.normalize("NFKC").str.lower()
# 改行コードを取り除く
df.text = df.text.str.replace("\n", " ")


# 辞書のパス取得
dicdir = subprocess.run(["mecab-config", "--dicdir"], capture_output=True, text=True).stdout.strip()
# 今回は品詞による絞り込みも原型への変換も行わないので -Owakati で実行する。
tagger = MeCab.Tagger(f"-Owakati -d {dicdir}/ipadic")


# 分かち書きした結果を返す。
def mecab_tokenizer(text):
    # 末尾に改行コードがつくのでstrip()で取り除く
    return tagger.parse(text).strip()


# 動作確認
print(mecab_tokenizer("すもももももももものうち"))
# すもも も もも も もも の うち

# 分かち書き
df["tokens"] = df.text.apply(mecab_tokenizer)

# 普段指定している token_pattern で学習
bow_model_1 = CountVectorizer(token_pattern='(?u)\b\w+\b', min_df=10)
bow_model_1.fit(df["tokens"])
# 学習した語彙数
print(len(bow_model_1.vocabulary_))
# 15332

# # token_pattern を \w+ だけにしたもの
bow_model_2 = CountVectorizer(token_pattern='\w+', min_df=10)
bow_model_2.fit(df["tokens"])
# 学習した語彙数
print(len(bow_model_2.vocabulary_))
# 15332

語彙数が同じなだけでなく、学習した単語の中身も全く同じです。

# 学習した単語は一致する
set(bow_model_1.get_feature_names()) == set(bow_model_2.get_feature_names())
# True

さて、本題に戻ります。\\w+ でこれにマッチする単語が抜き出せるとなれば、単純にスペース以外にマッチする正規表現を書いてあげれればそれで解決です。
“[^ ]+” (ハット”^”と閉じ大括弧”]”の間にスペースを忘れないでください)や、\\S+ などを使えばOKです。
それぞれ試しておきます。

bow_model_3 = CountVectorizer(token_pattern='\S+', min_df=10)
bow_model_3.fit(df["tokens"])
print(len(bow_model_3.vocabulary_))
# 15479

bow_model_4 = CountVectorizer(token_pattern="[^ ]+", min_df=10)
bow_model_4.fit(df["tokens"])
print(len(bow_model_4.vocabulary_))
# 15479

学習した語彙数が増えましたね。

「セ・リーグ」とか「ウォルト・ディズニー」が「・」で区切られずに学習されているのがわかりますよ。

print("セ・リーグ" in bow_model_1.get_feature_names())  # \w+のモデルでは学習されていない。
# False

print("セ・リーグ" in bow_model_3.get_feature_names())  # \S+のモデルでは学習されている
# True

print("ウォルト・ディズニー" in bow_model_1.get_feature_names())  # \w+のモデルでは学習されていない。
# False

print("ウォルト・ディズニー" in bow_model_3.get_feature_names())  # \S+のモデルでは学習されている
# True

これで、変なところで区切られずにMeCabで切った通りの単語で学習ができました。

ただし、この方法でもデメリットがないわけではありません。
\\w+ にはマッチしないが、\\S+にはマッチする文字がたくさん存在するのです。
要するに\\Wにマッチする文字たちのことです。

「?」や「!」などの感嘆符や「◆」のようなこれまで語彙に含まれなかった文字やそれを含む単語も含まれるようになります。これらが不要だという場合は、分かち書きする前か後に消しておいた方が良いでしょう。

もしくは逆に、不要に切ってほしくない文字は「- (ハイフン)」と「・(中点)」だけなんだ、みたいに特定できているのであれば、token_pattern=”[\w\-・]+” みたいに指定するのも良いと思います。(ハイフンは正規表現の[]内で使うときは文字の範囲指定を意味する特殊文字なのでエスケープ必須なことに気をつけてください。)

いずれにせよ、結果を慎重に検証しながら使った方が良さそうです。