BeautifulSoupを使って不要なタグとルビを取り除く

以前の記事で、青空文庫から取得したテキストの文字化けを治しました。
次は、不要なタグを除去します。

正規表現でやってしまえば早いのですが、せっかくなので、BeautifulSoupの使い方の確認も兼ねてこちらを使ってみました。

前提として、
htmlという変数に、銀河鉄道の夜のページのソースが入っているものとします。


# ライブラリのインポートと、soupオブジェクトへの変換
from bs4 import BeautifulSoup
soup = BeautifulSoup(html)

soup.find([タグ名]) や、 soup.find(class_=[class名])で、中のタグを指定することができます。
さらに、get_text()関数を使うと、タグを取り除いた文字列が表示されます。
これで div や h1,h2,…や、a,brタグなど不要タグはほぼほぼ除去できます。
ついでに、不要な前後の空白をstrip()で取り除いて、
300文字を表示してみましょう。


print(soup.find(class_="main_text").get_text().strip()[:300])

# 結果
一、午后(ごご)の授業

「ではみなさんは、そういうふうに川だと云(い)われたり、乳の流れたあとだと云われたりしていたこのぼんやりと白いものがほんとうは何かご承知ですか。」先生は、黒板に吊(つる)した大きな黒い星座の図の、上から下へ白くけぶった銀河帯のようなところを指(さ)しながら、みんなに問(とい)をかけました。
 カムパネルラが手をあげました。それから四五人手をあげました。ジョバンニも手をあげようとして、急いでそのままやめました。たしかにあれがみんな星だと、いつか雑誌で読んだのでしたが、このごろはジョバンニはまるで毎日教室でもねむく、本を読むひまも読む本もないので、なんだかどんなことも

さて、残りは 午后(ごご) などのルビです。

これも不要なので取り除きます。
該当部分のソースコードを見ると、下記のように、ruby, rb, rt, rpの4つのタグがあります。
このうち、 rubyとrbは、タグの中身は残したいので、get_text()で取り除けば十分ですが、rbとrtはタグとその中身を消す必要がります。


一、<ruby><rb>午后</rb><rp>(</rp><rt>ごご</rt><rp>)</rp></ruby>の授業

それには、decompose関数を使用します。


for tag in soup.findAll(["rt", "rp"]):
    # タグとその内容の削除
    tag.decompose()

参考ですが、タグだけを消して、中身を残す時はunwarpを使います。
(昔はreplaceWithChildrenという名前だったメソッドです。pep8対応のためにリネームされたとか。)
hxタグとかbrタグとか、これを使って消してたこともあるのですが、get_text()を使うようになっていらなくなりました。

これで取り除けたはずなので、もう一度本文を表示します。


print(soup.find(class_="main_text").get_text().strip()[:300])

# 結果

一、午后の授業

「ではみなさんは、そういうふうに川だと云われたり、乳の流れたあとだと云われたりしていたこのぼんやりと白いものがほんとうは何かご承知ですか。」先生は、黒板に吊した大きな黒い星座の図の、上から下へ白くけぶった銀河帯のようなところを指しながら、みんなに問をかけました。
 カムパネルラが手をあげました。それから四五人手をあげました。ジョバンニも手をあげようとして、急いでそのままやめました。たしかにあれがみんな星だと、いつか雑誌で読んだのでしたが、このごろはジョバンニはまるで毎日教室でもねむく、本を読むひまも読む本もないので、なんだかどんなこともよくわからないという気持ちがするので

綺麗にルビが消えました。

pipでライブラリをアップデートする

pipの使い方メモです。

まず、インストール済みのパッケージについての情報は pip list で確認できます。
更新版があるパッケージのみ出力するオプションは -o または --outdatedです。


$ pip list --outdate
Package Version Latest Type
------------------ --------- ---------- -----
alabaster 0.7.11 0.7.12 wheel
astroid 2.0.4 2.1.0 wheel
astropy 3.0.4 3.1.1 wheel
beautifulsoup4 4.6.3 4.7.1 wheel
bleach 2.1.4 3.1.0 wheel
bokeh 0.13.0 1.0.4 sdist
certifi 2018.8.24 2018.11.29 wheel
click 6.7 7.0 wheel
~~~ 以下略 ~~~

アップデートしたいパッケージを決めたら、
pip install に、
 -U か --upgrade のどちらかのオプションをつけてパッケージを指定し実行するとアップデートできます。

例:


$ pip install --upgrade scikit-learn

requestsのレスポンスが文字化けする場合に文字コードを修正する

非常に手軽にhttpアクセスができるrequestsですが、日本語の文書を取得する時に文字コードが正常に取れないケースがあります。

たとえば、今回は青空文庫の羅生門のページで発生しました。


import requests
url = "https://www.aozora.gr.jp/cards/000879/files/127_15260.html"
response = requests.get(url)
html = response.text

これで取得したhtml変数の中身を見るとひどいことに。

~略~
<div class="main_text"><br/>\r\n\x81@\x82\xa0\x82é\x93ú\x82Ì\x95é\x95û\x82Ì\x8e\x96\x82Å\x82\xa0\x82é\x81B\x88ê\x90l\x82Ì<ruby><rb>\x89º\x90l</rb><rp>\x81i</rp><rt>
~略~

問題は文字コードを正常に取れていないことのようです。
サイトのメタタグでは Shift_JIS が指定されていますが、
print(response.encoding)
を実行すると、
ISO-8859-1
が戻ってきます。

このような時は、apparent_encodingを使います。
ドキュメントを見る限りでは他のライブラリの機能を取り込んでるようですね。

response.apparent_encoding に、正しい文字コードである SHIFT_JISが格納されているので、
これをencodingにセットしてあげれば大丈夫です。


import requests
url = "https://www.aozora.gr.jp/cards/000879/files/127_15260.html"
response = requests.get(url)
# この下の行を追加
response.encoding = response.apparent_encoding
html = response.text

これで、htmlに文字化けしていないテキストが入りました。

requestsを使って、Webサイトのソースコードを取得する

今回はとりあえず単純に httpで getするだけのコードを紹介します。
サンプルとして、yahooのトップページのHTMLを取得します。

利用するのは、 requests というpythonのライブラリです。
ドキュメントにある通り、超手軽に使えます。

こちらのコードで、htmlという変数に結果が入ります。


import requests
url = "https://www.yahoo.co.jp/"
response = requests.get(url)
html = response.text

matplotlibのデフォルトのフォントを変更する

前の記事でmatplotlibで日本語を表示できるフォントをインストールしましたので、
この記事では実際にそのフォントを使う方法を書いておきます。

最初に、デフォルトのフォントのままだと、グラフがどのように表示されるのかを見ておきましょう。


import matplotlib.pyplot as plt
# デフォルトの設定を確認
print(plt.rcParams["font.family"]) # => ['sans-serif']
fig = plt.figure()
ax = fig.add_subplot(111)
ax.plot(range(10),range(10), label="データ1")
ax.plot(range(10),range(0,20,2), label="データ2")
ax.set_title("タイトル")
ax.set_xlabel("x軸のラベル")
ax.set_ylabel("y軸のラベル")
ax.legend()
plt.show()

こちらのコードを実行した結果がこの画像です。日本語文字が豆腐のようになります。

これを回避する方法の一つは実行するたびにフォントを指定することです。
このように、font.familyを指定することで、日本語の文字も豆腐にならず表示されます。


import matplotlib.pyplot as plt
plt.rcParams["font.family"] = "IPAexGothic"

このように指定したあとに、上と同じプログラムを実行すると、下図のように正しく日本語が表示されます。

ただし、これは毎回書くのは結構面倒です。たった1行なのに。
そこで、対応としてmatplotlibの設定ファイルでフォントを指定します。

最初に下記のコードを実行して、設定ファイルの場所を確認します。


>>> import matplotlib
>>> print(matplotlib.matplotlib_fname()) 

環境によって結果は変わりますが、自分の場合は下記の場所にありました。
/Users/<ユーザー名>/.pyenv/versions/anaconda3-5.3.1/lib/python3.7/site-packages/matplotlib/mpl-data/matplotlibrc

このmatplotlibrcのバックアップを取って編集します。
ファイル内に下記の記載があるのでコメントアウトを解除してIPAexGothicを指定します。
元の記述
#font.family : sans-serif
修正後
font.family : IPAexGothic

これで次回以降はフォントの指定をしなくてもmatplotlibで日本語が使えます。

もしうまく表示されない場合はキャッシュファイルを一度削除する必要があります。
matplotlib.get_cachedir()
でキャッシュのディレクトリがわかるので、ここにあるファイルを消して試してみてください。

MeCab.Tagger()はかなり遅いという話

昔、形態素解析にかかる時間を短縮するために調べた内容のメモです。

以前の記事で、mecab-python3 の使い方を書いたとき、tagger = MeCab.Tagger() という処理を関数の外側で行なっていました。

実は初めてmecab-python3を使った頃、僕は次のように書いてました。


def mecab_tokenizer(text):
    # 関数の中で、MeCab.Tagger()を呼び出す。これが遅い
    tagger = MeCab.Tagger()
    parsed_text = tagger.parse(text)
    parsed_lines = parsed_text.split("\n")[:-2]
    surfaces = [l.split('\t')[0] for l in parsed_lines]
    features = [l.split('\t')[1] for l in parsed_lines]
    bases = [f.split(',')[6] for f in features]
    # ここに、必要な品詞の単語だけ選抜する処理を入れることもある
    result = [b if b != '*' else s for s, b in zip(surfaces, bases)]
    return result

1個や2個のテキストを処理する分にはこの書き方で問題なかったのですが、
数十万件のテキストを処理するとこの関数がとても遅いという問題があり、調査をしていました。

結果わかったことは、タイトルの通り、MeCab.Tagger()が遅いということです。
jupyter で コードの前に %timeit とつけると時間を測れるのでやってみます。


%timeit tagger=MeCab.Tagger()
```
結果:
217 µs ± 6.17 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
```

ちなみに、形態素解析自体(parse)の実行時間はこちら


# 100文字のテキストを事前に用意しておきます
print(len(text))
```
100
```
%timeit parsed_text = tagger.parse(text)
```
結果:
26.9 µs ± 151 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each)
```

テキストがもっと長くなると話も変わるのですが、100文字くらいのテキストであれば、
parseにかかる時間よりも、Taggerのオブジェクトを作るのにかかる時間の方が8くらいかかっています。

対象のテキスト数(=関数が呼び出される回数)が数十万〜数百万件になってくると、
体感スピードがかなり違うので、
tagger = MeCab.Tagger()
は関数の中ではなく、事前に行うようにしておきます。

名前空間を汚染したりすることが気になる場合は、 class化するなどの対応をとりましょう。
また、形態素解析するテキストの数が少ない場合はあまり気にしなくても大丈夫です。

完全に余談ですが、この記事を書くために私物のMacで時間を計測したとき、職場のMacよりはるかに速いので感動しました。
職場の端末だとMeCab.Tagger()に 1.2ms (6倍!)かかります。
端末が5年物とそこそこ古いだけでなく、辞書指定などの問題もあるかもしれません。

matplotlibで等高線

折れ線グラフや散布図に比べると利用頻度が落ちますが、
2次元から1次元への写像の可視化として等高線を使うことがあるので、そのメモです。

使う関数は、線を引く場合は、contour,色を塗る場合は contourf を使います。

サンプルの関数は何でもいいのですが、今回はこれを使います。
$$
1-\exp(-x^2+2xy-2y^2)
$$
まずはデータの準備です。


import matplotlib.pyplot as plt
import numpy as np

# 関数の定義
def f(x, y):
    return 1- np.exp(-x**2 + 2*x*y - 2*y**2) 

# プロットする範囲のmeshgridを作成する。
X = np.linspace(-2,2,41)
Y = np.linspace(-2,2,41)
xx, yy = np.meshgrid(X, Y)

そして可視化してみます。まずは等高線から。


fig = plt.figure()
ax = fig.add_subplot(1,1,1)
ax.contour(xx,yy,f(xx, yy))
plt.show()

次に、色を塗る場合。


fig = plt.figure()
ax = fig.add_subplot(1,1,1)
ax.contourf(xx,yy,f(xx, yy), alpha=0.5)
plt.show()

色の指定などをきちんとしていないのですが、まあまあみやすく可視化できていますね。
機械学習の決定境界の可視化などでも、これと同じ方法を使うことがあります。

データフレームの特定の文字列を含む行を抽出するときに None の列があってもエラーにならないようにする

自然言語処理をよくやっているので、データフレームに格納されたテキストデータから、
特定の文字列を含むものを抽出する作業は非常に頻繁に発生します。
そのときには、 pandas.Series.str.contains という非常に便利な関数を使うのですが、
Series の中に None や Nan があるとエラーになるのが地味に不便でした。


print(df)
```
出力結果:
     col
0  あいうえお
1  かきくけこ
2   None
3  たちつてと
```
df[df["col"].str.contains("く")]
```
出力結果:
(略)
ValueError: cannot index with vector containing NA / NaN values
```

まあ、空白文字列か何かで埋めてあげれば何の問題もないのですが、このエラーが出ると嫌な気持ちになります。
気をつけていてもデータフレーム同士を結合したりするとすぐ None は発生しますし。

これはしょうがないと思っていたのですが、ドキュメントを見ていると、na というパラメーターがー準備されているのを見つけました。

contains の引数に na=True を指定すると,Noneの列も抽出され、na=Falseとすると、Noneの列は含みません。


print(df[df["col"].str.contains("く",na=False)])
```
出力結果:
     col
1  かきくけこ
```
print(df[df["col"].str.contains("あ",na=True)])
```
出力結果:
     col
0  あいうえお
2   None
```

これは便利ですね。
空文字列と、Noneを区別したい場面も結構あるのでNoneをそのまま残せるのはありがたいです。

また、ついでですが、 regex というパラメーターで、正規表現の使用未使用を切り替えられることにも気づきました。
デフォルトで正規表現が使えるのでいつも便利だと思っていたのですが、
完全一致のみにすることもできたのですね。

numpy の数値を表示するときの桁数を指定する

当然ですが、numpyを使っていると数値をprintして値を確認する機会が多々あります。
そこで問題になるのが、表示形式です。
本来は利便性のためだと思うのですが、小数点以下の桁が何桁も表示されたり、突然指数表記になったりします。
正直言って、配列内のどの値が大きくてどの値が小さいのか、ぱっと見でわかりにくいです。

表示例。


>>> import numpy as np
>>> ary = np.random.randn(3, 5)
>>> print(ary)
[[-8.69277072e-01 -4.72662319e-01  5.48868554e-01 -6.03789326e-01 1.95117216e-01]
 [-1.46386861e+00  9.92037075e-01  8.04045031e-01 -1.43756938e+00 7.46898368e-02]
 [-1.05065247e+00  3.72571551e-04 -1.15836779e-01 -5.80949053e-03 1.59979389e+00]]

numpy のドキュメントによると、絶対値が一番大きいものと一番小さいものの差が一定値を超えると指数表記になるそうです。

そこで、値を確認するときは、適当なくらいで四捨五入して表示したりしていたのですが、
実はnumpyのオプションで表示桁数を指定できることがわかりました。

設定を変える前に、デフォルトの設定は下記の関数で見ることができます。
(numpyのバージョンによって設定可能項目は変わります。)


>>> np.get_printoptions()
{'edgeitems': 3, 'threshold': 1000, 'floatmode': 'maxprec', 'precision': 8, 'suppress': False, 'linewidth': 75, 'nanstr': 'nan', 'infstr': 'inf', 'sign': '-', 'formatter': None, 'legacy': False}

各設定値の意味はこちら。set_printoptions
(get_printoptionsのページにはset_printoptions を見ろと書いてある。)

これらの設定値を、set_printoptions関数で変更することができます。
この中で、よく使うのはこの二つ。
precision = 3 # 小数点以下の表記を
suppress = True # 指数表記を禁止

設定してみたのがこちら。


>>> np.set_printoptions(precision=3, suppress=True)
>>> ary = np.random.randn(5,3)
>>> print(ary)
[[ 1.611 -2.259  0.022]
 [-1.937 -0.394  2.011]
 [-0.01  -0.162 -0.823]
 [-1.818 -2.474  0.341]
 [ 0.363 -2.018 -0.667]]

見やすくなりました。

mecab-python3をつかってみる

前回の記事でインストールした mecab-python3 の使い方を書いておきます。
MeCabについてはWikiがあるのですが、このライブラリについては詳細なマニュアルはなく、
リポジトリの test.py を読むようにとそっけなく書いてあります。

ただ、実際のところつかのは非常に簡単です。
次の例のようにMeCab.Tagger() と parse を呼び出すだけで結果を得られます。


>>> import MeCab
>>> text = 'すもももももももものうち'
>>> tagger = MeCab.Tagger()
>>> print(tagger.parse(text))
すもも	名詞,一般,*,*,*,*,すもも,スモモ,スモモ
も	助詞,係助詞,*,*,*,*,も,モ,モ
もも	名詞,一般,*,*,*,*,もも,モモ,モモ
も	助詞,係助詞,*,*,*,*,も,モ,モ
もも	名詞,一般,*,*,*,*,もも,モモ,モモ
の	助詞,連体化,*,*,*,*,の,ノ,ノ
うち	名詞,非自立,副詞可能,*,*,*,うち,ウチ,ウチ
EOS

各行の出力結果は次の形です。

表層形\t品詞,品詞細分類1,品詞細分類2,品詞細分類3,活用型,活用形,原形,読み,発音

注意点としては、 parseした戻り値は一つのテキストなので非常に使いにくいことです。

多くの場合、必要なのは原型の列です。
そこでプログラムでこのテキストから原形の情報を取り出すことになります。
僕はいつも下記のような関数を作って実行しています。


tagger = MeCab.Tagger()


def mecab_tokenizer(text):
    parsed_text = tagger.parse(text)
    parsed_lines = parsed_text.split("\n")[:-2]
    surfaces = [l.split('\t')[0] for l in parsed_lines]
    features = [l.split('\t')[1] for l in parsed_lines]
    bases = [f.split(',')[6] for f in features]
    # ここに、必要な品詞の単語だけ選抜する処理を入れることもある
    result = [b if b != '*' else s for s, b in zip(surfaces, bases)]
    return result