Luhnの要約アルゴリズムの本処理

Luhnの要約アルゴリズムの前処理の続き。 記事本文を"。"で分割して文のリストにし、さらに英語を小文字化したのがnormalized_sents(以下、sents)だった。 ここでは、分割された各文がどのようにスコア付けされるかを見ていく。

文を単語に分割する

今回は、以下の文の処理の流れを追っていく。

googleによるとgnmtは一部のケースでは人間レベルの翻訳が可能なレベルに達しているとのこと

nltkjp.word_tokenize()分かち書きを行うメソッド。

scores = []
sent_idx = -1
nltkjp = NLTKJP()
for s in [nltkjp.word_tokenize(sent) for sent in sents]:
    sent_idx += 1

文が単語に分割されたので、sの中身は次のようになる。

>>> for d in s:
>>>     print d,
>>> print
google に よる と gnmt は 一部 の ケース で は 人間 レベル の 翻訳 が 可能 な レベル に 達し て いる と の こと

文中の重要単語の位置を把握する

Luhnの要約アルゴリズムの前処理 で、頻出名詞Top100をimportant_wordsとして取得した。 これらが文中のどこに位置するかを把握する。

    word_idx = []

    # 単語リスト中の個々の単語について
    for w in important_words:
        try:
            # 文中の重要単語が出現した位置のインデックスを計算する
            word_idx.append(s.index(w))
        except ValueError, e:  # この文にはwが含まれていない
            pass

    word_idx.sort()

    # 一部の文は、重要単語を1つも含んでいないことがありえる
    if len(word_idx) == 0:
        continue

位置は次のようになる。

>>> print word_idx
[0, 4, 6, 8, 11, 12, 14, 16]

実際に単語に分割した文と重要単語の位置を比較すると、次のようになる。 上記のアルゴリズムでは文中に同じ重要単語が出てきた時にカウントできないことが分かる。 次の例では"レベル"が12と18にあるが、12しかカウントされていない。 意図的ではなくミスだと思うので、後で改修する。

文中の重要単語の距離からクラスタリングを行う

文中の重要単語間の距離が3以内であれば、それらを1つのクラスタとする。 オリジナルの距離は5。

    self.CLUSTER_THRESHOLD = 3  # 考慮する単語の間の距離

    # 単語のインデックスを使って2つの連続する単語に対して
    # 最大距離の閾値を使ってクラスタを計算する
    clusters = []
    cluster = [word_idx[0]]
    i = 1
    while i < len(word_idx):
        if word_idx[i] - word_idx[i-1] < self.CLUSTER_THRESHOLD:
            cluster.append(word_idx[i])
        else:
            clusters.append(cluster[:])
            cluster = [word_idx[i]]
        i += 1
    clusters.append(cluster)

実行すると3つのクラスタに分類された。

>>> print clusters
[[0], [4, 6, 8], [11, 12, 14, 16]]

クラスタのスコアを計算する

クラスタ内の重要単語数の二乗を、クラスタの最初と最後の距離で割った値を、そのクラスタのスコアとする。 最終的に各クラスタのスコアの最大値がその文のスコアになる。

    # 各クラスタのスコアを計算。クラスタのスコアの最大値がその文のスコア
    score_list = []
    max_cluster_score = 0
    for c in clusters:
        swc = len(c)  # significant_words_in_cluster
        twc = c[-1] - c[0] + 1  # total_words_in_cluster
        score = 1.0 * swc*swc / twc
        score_list.append(score)

        if score > max_cluster_score:
            max_cluster_score = score

    scores.append((sent_idx, max_cluster_score))

3つのクラスタとスコアとその最大値(文のスコア)は次のようになる。

>>> print score_list
[1.0, 1.8, 2.7]

>>> print max_cluster_score
2.7

まとめ

以上より、文のスコア付けの流れは次のようになる。

1. googleによるとgnmtは一部のケースでは人間レベルの翻訳が可能なレベルに達しているとのこと
2. google に よる と gnmt は 一部 の ケース で は 人間 レベル の 翻訳 が 可能 な レベル に 達し て いる と の こと
3. [0, 4, 6, 8, 11, 12, 14, 16]
4. [[0], [4, 6, 8], [11, 12, 14, 16]]
5. [1.0, 1.8, 2.7]
6. 2.7

スコア計算メソッドは次の通り。

def score_sentences(self, sents, important_words):
    """
    H.P. Luhn, "The Automatic Creation of Literature Abstracts"によるアプローチ
    """
    scores = []
    sent_idx = -1

    nltkjp = NLTKJP()
    for s in [nltkjp.word_tokenize(sent) for sent in sents]:
        sent_idx += 1
        word_idx = []

        # 単語リスト中の個々の単語について
        for w in important_words:
            try:
                # 文中の重要単語が出現した位置のインデックスを計算する
                word_idx.append(s.index(w))
            except ValueError, e:  # この文にはwが含まれていない
                pass
        word_idx.sort()

        # 一部の文は、重要単語を1つも含んでいないことがありえる
        if len(word_idx) == 0:
            continue

        # 単語のインデックスを使って2つの連続する単語に対して
        # 最大距離の閾値を使ってクラスタを計算する
        clusters = []
        cluster = [word_idx[0]]
        i = 1
        while i < len(word_idx):
            if word_idx[i] - word_idx[i-1] < self.CLUSTER_THRESHOLD:
                cluster.append(word_idx[i])
            else:
                clusters.append(cluster[:])
                cluster = [word_idx[i]]
            i += 1
        clusters.append(cluster)

        # 各クラスタのスコアを計算。クラスタのスコアの最大値がその文のスコア
        score_list = []
        max_cluster_score = 0
        for c in clusters:
            swc = len(c)  # significant_words_in_cluster
            twc = c[-1] - c[0] + 1  # total_words_in_cluster
            score = 1.0 * swc*swc / twc
            score_list.append(score)

            if score > max_cluster_score:
                max_cluster_score = score

        scores.append((sent_idx, max_cluster_score))

    return scores

Luhnの要約アルゴリズムの前処理

入門 ソーシャルデータ ―データマイニング、分析、可視化のテクニック

入門 ソーシャルデータ ―データマイニング、分析、可視化のテクニック

入門 ソーシャルデータ ―データマイニング、分析、可視化のテクニックの8.4章に、Luhnのプリミティブな要約アルゴリズムの解説がある。 このアルゴリズムは非常にシンプルであるものの、そこそこの精度が出せる。 解説では英語のみの対応となっていたため、日本語にも適用できるようにしてみた。

アルゴリズムは、基本的な自然言語処理を行う前処理と、Luhnの要約アルゴリズムとなる本処理、 要約を出力するための後処理に別れる。 ここではまず前処理について説明する。

要約対象テキスト

Google翻訳は人間レベルの翻訳精度を目指して人工知能を活用 の記事本文を要約対象としてみる。

text = "スマートフォンでもPCでもオフラインでも使え、100以上の言語に対応しているGoogleの翻訳サービス「Google翻訳」が、ちょうど10周年を迎えるタイミングでより自然な翻訳を可能にする人工知能(AI)を活用した「GNMT」システムを発表しました。\
AI研究で複数の大手IT企業とのパートナーシップを結んだばかりのGoogleは、AIを駆使した翻訳システム「GNMT(Google Neural Machine Translation)」を発表しました。Google翻訳ではこれまでフレーズベースで機械翻訳するPBMTという\
システムが採用されていましたが、このような単語やフレーズごとに機械的に文章を翻訳する方法ではなく、文章全体をひとつの翻訳単位として捉えることができるのがGNMTだそうです。Googleの研究者によると、「GNMTの『文章全体をひとつの翻訳単位\
として捉える』アプローチの長所は、工学的設計の選択肢がPBMTよりも少なくて済むことです」とのこと。実際にGNMTを駆使した最初の翻訳では、既存のGoogle翻訳と遜色ない翻訳精度がみられたそうです。さらに、何度も翻訳を重ねることで、\
GNMTは優れた翻訳と素早い翻訳スピードの両立が可能になっている模様。Googleによると、GNMTを用いることでGoogle翻訳は翻訳ミスを55~85%も軽減できるようになるとのこと。GoogleによるとGNMTは一部のケースでは人間レベルの翻訳が\
可能なレベルに達しているとのこと。以下のグラフは人間・GNMT・PBMTの3つによる翻訳を6段階評価して比較したもので、最も翻訳精度が高いのは人間による翻訳ですが、フランス語から英語に翻訳する場合や英語からスペイン語に翻訳する場合、\
人間とGNMTの間にそれほど大きな差は存在しないそうです。"

テキストを文に分割

自作の日本語の自然言語処理ライブラリー nltkjp.py (詳しくはここここを参照) に、「。」で分割してリスト化するだけの処理を行うメソッド sent_tokenize() を追加した。

def sent_tokenize(self, doc):
    sents = [d.decode("utf-8") for d in doc.split(u'。')]

    return sents

まずはテキストをこれにかける。

nltkjp = NLTKJP()

# テキストを文に分割
sents = nltkjp.sent_tokenize(text)
>>> for sent in sents:
>>>     print sent
スマートフォンでもPCでもオフラインでも使え、100以上の言語に対応しているGoogleの翻訳サービス「Google翻訳」が、ちょうど10周年を迎えるタイミングでより自然な翻訳を可能にする人工知能(AI)を活用した「GNMT」システムを発表しました
AI研究で複数の大手IT企業とのパートナーシップを結んだばかりのGoogleは、AIを駆使した翻訳システム「GNMT(Google Neural Machine Translation)」を発表しました
Google翻訳ではこれまでフレーズベースで機械翻訳するPBMTというシステムが採用されていましたが、このような単語やフレーズごとに機械的に文章を翻訳する方法ではなく、文章全体をひとつの翻訳単位として捉えることができるのがGNMTだそうです
...

文をすべて小文字に変換

英語の大文字を小文字に変換する。 本当は日本語の動詞もすべて基本形にするとかしないといけないのかもしれないけど、また今後。

normalized_sents = [sent.lower() for sent in sents]
>>> for ns in normalized_sents:
>>>     print ns
スマートフォンでもpcでもオフラインでも使え、100以上の言語に対応しているgoogleの翻訳サービス「google翻訳」が、ちょうど10周年を迎えるタイミングでより自然な翻訳を可能にする人工知能(ai)を活用した「gnmt」システムを発表しました
ai研究で複数の大手it企業とのパートナーシップを結んだばかりのgoogleは、aiを駆使した翻訳システム「gnmt(google neural machine translation)」を発表しました
google翻訳ではこれまでフレーズベースで機械翻訳するpbmtというシステムが採用されていましたが、このような単語やフレーズごとに機械的に文章を翻訳する方法ではなく、文章全体をひとつの翻訳単位として捉えることができるのがgnmtだそうです
...

各文を単語に分割して1つにまとめる

各文章を分かち書きにしてすべてまとめる。

words = [word.lower() for sent in normalized_sents for word in nltkjp.word_tokenize(sent)]
>>> for word in words[:10]:
>>>     print word
スマート
フォン
で
も
pc
で
も
オフライン
で
も
...

頻出単語を抽出する

ここで本家のnltkが登場。FreqDistに単語のリストを入れると、単語をカウントしてソートしてくれる。

fdist = nltk.FreqDist(words)
>>> for k,v in fdist.items()[:10]:
>>>     print k, '\t', v
printの    24
翻訳  23151414131211
gnmt    10
google  10
...

ストップワードでない上位N個の単語を抽出する

頻出単語からストップワードを除き、そのうちの上位N個を取得する。 100にしてるのは、解説がそうだったからだけど、今回の例では多い気がする。 ストップワードの取得については、以下の結果に"("が含まれていたりとまだ不備があるけど、アルゴリズム簡易的な日本語ストップワードの取得メソッドを参照。 ただし、今回はストップワードの品詞は名詞以外としている。

stopwords = nltkjp.stopwords(text)
N = 100
top_n_words = [w[0] for w in fdist.items() if w[0] not in stopwords][:N]
>>> for tnw in top_n_words[:10]:
>>>     print tnw
翻訳
gnmt
google
人間
ai
pbmt
システム
可能
文章
(
...

まとめ

以上が前処理となる。 各処理をまとめると以下のようになる。

import nltk
from nltkjp import NLTKJP

N = 100  # 考慮する単語の数

def main(text):
    nltkjp = NLTKJP()

    # テキストを文に分割
    sents = nltkjp.sent_tokenize(text)

    # 文をすべて小文字に変換
    normalized_sents = [sent.lower() for sent in sents]

    # 各文を単語に分割して1つにまとめる
    words = [word.lower() for sent in normalized_sents for word in nltkjp.word_tokenize(sent)]

    # 頻出単語を抽出する
    fdist = nltk.FreqDist(words)

    # ストップワードでない上位N個の単語を抽出する
    stopwords = nltkjp.stopwords(text)
    top_n_words = [w[0] for w in fdist.items() if w[0] not in stopwords][:N]

簡易的な日本語ストップワードの取得メソッド

それほど厳密に調査した訳ではないが、NLTKのコーパスには日本語のストップワードが存在しないようで、多くの人は SlothLib を利用している、という印象をWebから受けた。 SlothLibのように単語ベースでストップワードを定義している一方で、 IBM Content Analytics のように、シンプルに名詞か動詞以外の品詞の単語をストップワードとしているのもあった。 今回は、これら2種類の定義により、入力したテキストのストップワードを出力するメソッドを実装した。

品詞によるストップワードの取得

前回、単語と品詞のセットのリストが取得できるメソッド word_and_class() を作ったので、 そこから名詞と動詞以外の単語を取得する。

word_class = self.word_and_class(doc)
ok_class = [u"名詞", u"動詞"]
stopwords = []
for wc in word_class:
    if not wc[1] in ok_class:
        stopwords.append(wc[0])

サンプルテキストに「日本語の自然言語処理は本当にしんどい、と彼は十回言った。」を渡すと次のようになる。

>>> for sw in stopwords:
>>>    print sw,
の は 本当に しんどい 、 と は た 。

SlothLibによるストップワードの取得

SlothLibのテキストページ を取得してストップワードとする。

slothlib_path = 'http://svn.sourceforge.jp/svnroot/slothlib/CSharp/Version1/SlothLib/NLP/Filter/StopWord/word/Japanese.txt'
slothlib_file = urllib2.urlopen(slothlib_path)
slothlib_stopwords = [line.decode("utf-8").strip() for line in slothlib_file]
slothlib_stopwords = [ss for ss in slothlib_stopwords if not ss==u'']

実行結果は次の通り。

>>> for ss in slothlib_stopwords:
>>>    print ss,
あそこ あたり あちら あっち あと あな あなた あれ いくつ いつ いま いや いろいろ うち おおまか おまえ おれ がい かく かたち かやの から がら きた くせ ここ こっち こと ごと こちら ごっちゃ これ これら ごろ さまざま さらい さん しかた しよう すか ずつ すね すべて ぜんぶ そう そこ そちら そっち そで それ それぞれ それなり たくさん たち たび ため だめ ちゃ ちゃん てん とおり とき どこ どこか ところ どちら どっか どっち どれ なか なかば なに など なん はじめ はず はるか ひと ひとつ ふく ぶり べつ へん ぺん ほう ほか まさ まし まとも まま みたい みつ みなさん みんな もと もの もん やつ よう よそ わけ わたし ハイ 上 中 下 字 年 月 日 時 分 秒 週 火 水 木 金 土 国 都 道 府 県 市 区 町 村 各 第 方 何 的 度 文 者 性 体 人 他 今 部 課 係 外 類 達 気 室 口 誰 用 界 会 首 男 女 別 話 私 屋 店 家 場 等 見 際 観 段 略 例 系 論 形 間 地 員 線 点 書 品 力 法 感 作 元 手 数 彼 彼女 子 内 楽 喜 怒 哀 輪 頃 化 境 俺 奴 高 校 婦 伸 紀 誌 レ 行 列 事 士 台 集 様 所 歴 器 名 情 連 毎 式 簿 回 匹 個 席 束 歳 目 通 面 円 玉 枚 前 後 左 右 次 先 春 夏 秋 冬 一 二 三 四 五 六 七 八 九 十 百 千 万 億 兆 下記 上記 時間 今回 前回 場合 一つ 年生 自分 ヶ所 ヵ所 カ所 箇所 ヶ月 ヵ月 カ月 箇月 名前 本当 確か 時点 全部 関係 近く 方法 我々 違い 多く 扱い 新た その後 半ば 結局 様々 以前 以後 以降 未満 以上 以下 幾つ 毎日 自体 向こう 何人 手段 同じ 感じ

ストップワードの統合

上記の2種類のストップワードを取得する手法を統合して、1つのメソッドにすると次のようになる。

def stopwords(self, doc):
    """
    Get stopwords from input document.
    """
    # Judged by class
    word_class = self.word_and_class(doc)        
    ok_class = [u"名詞", u"動詞"]
    stopwords = []
    for wc in word_class:
        if not wc[1] in ok_class:
            stopwords.append(wc[0])

    # Defined by SlpothLib
    slothlib_path = 'http://svn.sourceforge.jp/svnroot/slothlib/CSharp/Version1/SlothLib/NLP/Filter/StopWord/word/Japanese.txt'
    slothlib_file = urllib2.urlopen(slothlib_path)
    slothlib_stopwords = [line.decode("utf-8").strip() for line in slothlib_file]
    slothlib_stopwords = [ss for ss in slothlib_stopwords if not ss==u'']

    # Merge and drop duplication
    stopwords += slothlib_stopwords
    stopwords = list(set(stopwords))

    return stopwords

テスト

サンプルテキストの分かち書きとそこからストップワードを除去したものを表示。

text = '日本語の自然言語処理は本当にしんどい、と彼は十回言った。'
sw = nltkjp.stopwords(text)

words = nltkjp.word_tokenize(text)
print '分かち書き:'
for w in words:
    print w,
print
print

print '分かち書き(ストップワードを除去):'
for w in words:
    if not w in sw:
       print w,
print

単語の分割方法や動詞の基本形の必要性など、まだ課題はあるが、 ストップワードを取得するという目的は達成できた。

分かち書き:
日本語 の 自然 言語 処理 は 本当に しんどい 、 と 彼 は 十 回 言っ た 。

分かち書き(ストップワードを除去):
日本語 自然 言語 処理 言っ

MeCab-Pythonで分かち書きと形態素解析

MeCabでwakatiとchasenを使うと、それぞれ分かち書き形態素解析ができる。 これらの結果を利用しやすくするために、前者は単語のリスト、後者は単語と品詞のセットのリストとして取得できるようにした。

分かち書き

まずはMeCabの使い方から。最初は分かち書き。 パラメーターに"-Owakati"を指定する。

text = '日本語の自然言語処理は本当にしんどい。'
tagger = MeCab.Tagger("-Owakati")        
result = tagger.parse(text)
print result

結果。これをリストにする。

日本語 の 自然 言語 処理 は 本当に しんどい 。

分かち書き結果をリストで取得

メソッドは以下の通り。 文字コードとか、不要な情報とかを除去してリスト化している。

def word_tokenize(self, doc):
    """
    Execute wakati.
    """
    # Convert string type in case of unicode type
    doc_ex = doc
    if type(doc) is types.UnicodeType:
        doc_ex = doc.encode("utf-8")

    # Execute wakati
    tagger = MeCab.Tagger("-Owakati")        
    result = tagger.parse(doc_ex)

    # Make word list
    ws = re.compile(" ")
    words = [word.decode("utf-8") for word in ws.split(result)]
    if words[-1] == u"\n":
        words = words[:-1]

    return words

結果は次の通り。 NLTKJPは今回作った2つのメソッドのクラス名。 nltkjp.word_tokenize()は、nltk.tokenize.word_tokenize()の日本語版のつもり。

>>> text = '日本語の自然言語処理は本当にしんどい。'
>>> from nltkjp import NLTKJP
>>> nltkjp = NLTKJP()
>>> result = nltkjp.word_tokenize(text)
>>> print result
[u'\u65e5\u672c\u8a9e', u'\u306e', u'\u81ea\u7136', u'\u8a00\u8a9e', u'\u51e6\u7406', u'\u306f', u'\u672c\u5f53\u306b', u'\u3057\u3093\u3069\u3044', u'\u3002']
>>>
>>> for r in result:
>>>     print r,
日本語 の 自然 言語 処理 は 本当に しんどい 。

形態素解析

次に形態素解析。 パラメーターに"-Ochasen"を指定。

tagger = MeCab.Tagger('-Ochasen')
result = tagger.parseToNode(text)
while result:
    print '%-10s \t %-s' % (result.surface, result.feature)
    result = result.next

surfaceで単語、featureで解析結果が得られる。 解析結果のうち、今回は品詞だけ必要になる。

              BOS/EOS,*,*,*,*,*,*,*,*
日本語    名詞,一般,*,*,*,*,日本語,ニホンゴ,ニホンゴ
の          助詞,連体化,*,*,*,*,の,ノ,ノ
自然       名詞,形容動詞語幹,*,*,*,*,自然,シゼン,シゼン
言語       名詞,一般,*,*,*,*,言語,ゲンゴ,ゲンゴ
処理       名詞,サ変接続,*,*,*,*,処理,ショリ,ショリ
は          助詞,係助詞,*,*,*,*,は,ハ,ワ
本当に    副詞,一般,*,*,*,*,本当に,ホントウニ,ホントーニ
しんどい     形容詞,自立,*,*,形容詞・アウオ段,基本形,しんどい,シンドイ,シンドイ
。          記号,句点,*,*,*,*,。,。,。
             BOS/EOS,*,*,*,*,*,*,*,*

分かち書き結果を単語と品詞のセットのリストで取得

メソッドは以下の通り。 解析結果をカンマ(,)でsplitして最初だけ取得。 あと、解析結果の最初と最後がいらないので除去。

def word_and_class(self, doc):
    """
    Get word and class tuples list.
    """
    # Convert string type in case of unicode type
    doc_ex = doc
    if type(doc) is types.UnicodeType:
        doc_ex = doc.encode("utf-8")

    # Execute class analysis
    tagger = MeCab.Tagger('-Ochasen')
    result = tagger.parseToNode(doc_ex)

    # Extract word and class
    word_class = []
    while result:
        word = result.surface.decode("utf-8", "ignore")
        clazz = result.feature.split(',')[0].decode('utf-8', 'ignore')
        if clazz != u'BOS/EOS':
            word_class.append((word, clazz))
        result = result.next

        return word_class

結果は次の通り。

>>> result = nltkjp.word_and_class(text)
>>> for r in result:
>>>     print '%-10s \t %-s' % (r[0], r[1])
日本語            名詞
の              助詞
自然           名詞
言語           名詞
処理           名詞
は              助詞
本当に            副詞
しんどい         形容詞
。              記号

新規リポジトリー同期のためのGitHub Desktop

GitHub Desktopを使って、GitHubで作った新規リポジトリーをローカルと同期させるための手順をまとめた。

手順

  1. ローカルでファイル作成
  2. GitHubリポジトリー作成
  3. GitHubリポジトリーにファイルをアップロード
  4. GitHub DesktopでGitHubリポジトリーをクローン
  5. 以下、ローカルでプロジェクトの編集

1. ローカルでファイル作成

適当な場所に作成。 今回はsrcフォルダー内に幾つかのファイルを作成した前提で話を進める。

2. GitHubリポジトリー作成

GitHubリポジトリーで[New]ボタンを押して作成。 .gitignoreも同時に作成。

3. GitHubリポジトリーにファイルをアップロード

2で作ったリポジトリーの[Upload files]ボタンを押して、 1で作ったsrcフォルダーをドラッグ・アンド・ドロップ。

4. GitHub DesktopでGitHubリポジトリーをクローン

[+] > [Clone]で、GitHub上のリポジトリーが表示されるので、 2で作ったリポジトリーを適当な場所にクローン。 1のsrcフォルダーは削除して構わない。

5. 以下、ローカルでプロジェクトの編集

ローカルでファイルを変更・追加すると、GitHub Desktopの[Uncommitted Changes]に表示される。 Commitしたいファイルにチェックボタンを付けて、SummaryとDescriptionにコメントを記入して、 [Commit to master]ボタンを押す。 この時点ではローカル保存なので、右上の[Sync]ボタンを押すとGitHubと同期される。

ちなみにコードではなくデータを保存しているフォルダーも、更新があった際は[Uncommitted Changes]に表示される。 このようなCommitしなくていいフォルダーは、.gitignoreにdata_dir/のようにと書いておくと、 [Uncommitted Changes]に表示されなくなり、変更ファイルが分かりやすくなる。

GIGAZINEの記事をPythonでスクレイピング

前回GIGAZINERSSをDBに保存した。今回はソースから本文を抽出してDBに保存する。

urllib2でソースを取得

リンクからソースを取得するには、urllib2.urlopen()を使うのが早い。 articleのlinkを渡す。取得したソースは、idをファイル名にして保存する。

def get_src(self, article):
    html = urllib2.urlopen(article['link'])
    src = html.read()

    f = open('../html/%s.html'%article['id'], 'w')
    f.write(src)
    f.close()

BeautifulSoupでソースをスクレイピング

ここが肝になる。4ステップを踏む。

  1. HTMLファイルの読み込み
  2. 本文の範囲を特定
  3. テキストの抽出
  4. クレンジング

1. 保存したHTMLファイルを読み込む

保存したソースをファイルとして読み込んだら、BeautifulSoupに渡す。 これはスクレイピングの準備。

soup = BeautifulSoup(src)

2. 本文の範囲を特定

Chromeデベロッパーツールを使うと、pタグでclass='preface'の範囲が記事になっていることが分かる。

f:id:Shoto:20161002003721p:plain 複数あるので、すべて取得しておく。

ps = soup.find_all('p', class_='preface')

3. テキストの抽出

2で取得したデータにはリンクや太字などのタグも含まれている。 そのため、タグをすべて取り除いたテキストのみを抽出するために、 次のメソッドに入れる。

# Extract the text from an HTML page (no tags)
def __getTextOnly(self, soup):
    v = soup.string  # Split by tags and check whether nested tags
    if v == None:  # If tags are nested
        c = soup.contents  # Eliminate outmost tags
        resulttext = ''
        for t in c:
            subtext = self.__getTextOnly(t)
            # If the subtext is null(u''), don't append it
            if len(subtext) > 0:
                resulttext += subtext + '\n'
        return resulttext
    else:
        return v.strip()  # Eliminate '\n'

ちなみにこのメソッドは集合知プログラミングで使われたテクニックの一つ。 結構使える。

集合知プログラミング

集合知プログラミング

4. クレンジング

3で抽出したテキストを見てみる。

By
Tony

お酒の飲み過ぎが体に悪いことは誰でも知っていることですが、
街で酔いつぶれている人
がいるように、自分では知らないうちに泥酔するほど飲み続けてしまうことがあります。お酒の席では「自分がどれだけ酔っ払っているか」をしっかり判断する必要があるわけですが、酔っ払いは自分が酔っ払っていることを正確に把握できないことが科学的に証明されました。
A rank based social norms model of how people judge their levels of drunkenness whilst intoxicated | BMC Public Health | Full Text
http://bmcpublichealth.biomedcentral.com/articles/10.1186/s12889-016-3469-z
Science shows that drunk people don’t know how drunk they are | Ars Technica
http://arstechnica.com/science/2016/09/science-shows-that-drunk-people-dont-know-how-drunk-they-are/
イギリスのカーディフ大学の研究チームは、ウェールズの首都カーディフのバーとクラブを巡って「お酒の席で飲んでいる人々が自分の『酔っ払い度』と飲酒がもたらす健康への影響をどれくらい判断できるか」ということを調査しました。研究チームは数カ月間にわたって毎週金曜日・土曜日の20時と3時にカーディフの異なる4つのエリアへ赴き、7番目に会った酔っ払いに調査の協力を依頼し、合計1862人の協力を得ることができました。この選択方法は「異なるグループから1人を無作為に選出するため」とのことです。
By
herooutoftime

依頼が承諾されると研究チームは協力者の呼気アルコールテストを行い、正確な酩酊レベルを記録。その後協力者に対して以下の4つの質問を行うという調査方法になっています。

とりあえず、いらないのは以下の4つぐらいなので、これらをルールベースで取り除いていく。

  • 空文字: u''(見えない)
  • 画像の引用元: By Tony
  • リンク: http://...
  • リンクタイトル: Science ... they are | Ars Technica
def __delete_not_text(self, text):
    text_ex = ''
    by = False
    for t in text.split('\n'):
        # nil string
        if t == u'':
            continue

        # by name
        if by == True:
            by = False
            continue
        if t == u'By' or t == u'by':
            by = True
            continue

        # link
        regex = 'http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+'
        urls = re.findall(regex, t)
        if len(urls) != 0:
            continue

        # link title
        if t.find(' - ') != -1:
            continue
        if t.find(' | ') != -1:
            continue

        # combine text
        text_ex += t

    return text_ex

処理後はちゃんとゴミが消えてることが分かる。

お酒の飲み過ぎが体に悪いことは誰でも知っていることですが、
街で酔いつぶれている人
がいるように、自分では知らないうちに泥酔するほど飲み続けてしまうことがあります。お酒の席では「自分がどれだけ酔っ払っているか」をしっかり判断する必要があるわけですが、酔っ払いは自分が酔っ払っていることを正確に把握できないことが科学的に証明されました。
イギリスのカーディフ大学の研究チームは、ウェールズの首都カーディフのバーとクラブを巡って「お酒の席で飲んでいる人々が自分の『酔っ払い度』と飲酒がもたらす健康への影響をどれくらい判断できるか」ということを調査しました。研究チームは数カ月間にわたって毎週金曜日・土曜日の20時と3時にカーディフの異なる4つのエリアへ赴き、7番目に会った酔っ払いに調査の協力を依頼し、合計1862人の協力を得ることができました。この選択方法は「異なるグループから1人を無作為に選出するため」とのことです。
依頼が承諾されると研究チームは協力者の呼気アルコールテストを行い、正確な酩酊レベルを記録。その後協力者に対して以下の4つの質問を行うという調査方法になっています。

以上の4ステップをまとめると、次のようになる。

def get_text(self, article):
    f = open('../html/%s.html'%article['id'])
    src = f.read()
    f.close()

    # 1. HTMLファイルの読み込み
    soup = BeautifulSoup(src)
    # 2. 本文の範囲を特定
    ps = soup.find_all('p', class_='preface')
    # 3. テキストの抽出
    text = ''.join([self.__getTextOnly(p) for p in ps])
    # 4. クレンジング
    text_ex = self.__delete_not_text(text)

    return text_ex

まとめ

前回RSS取得と今回の記事本文スクレイピングをまとめると次のようになる。

def main(self):
    rss = self.get_rss()
    self.save_db(rss)

    rss = self.read_db()
    for i in range(len(rss)):
        article = rss[i]
        self.get_src(article)
        text = self.get_text(article)
        article['text'] = text
    self.save_db(rss)

スクレイピング結果は次の通り。

    rss = self.read_db()        
    for article in rss[:3]:
        print 'title:\t', article['title']
        print 'link:\t', article['link']
        print 'text:\t', article['text']
        print
title:   酔っ払いは自分がどれくらい酔っ払っているか正確に判断できないことが科学的に判明
link:   http://gigazine.net/news/20161001-drunk-people-dont-know-how-drunk/
text:   お酒の飲み過ぎが体に悪いことは誰でも知っていることですが、街で酔いつぶれている人がいるように、自分では知らないうちに泥酔するほど飲み続けてしまうことがあります。お酒の席では「自分がどれだけ酔っ払っているか」をしっかり判断する必要があるわけですが、酔っ払いは自分が酔っ払っていることを正確に把握できないことが科学的に証明されました。イギリスのカーディフ大学の研究チームは、ウェールズの首都カーディフのバーとクラブを巡って「お酒の席で飲んでいる人々が自分の『酔っ払い度』と飲酒がもたらす健康への影響をどれくらい判断できるか」ということを調査しました。研究チームは数カ月間にわたって毎週金曜日・土曜日の20時と3時にカーディフの異なる4つのエリアへ赴き、7番目に会った酔っ払いに調査の協力を依頼し、合計1862人の協力を得ることができました。この選択方法は「異なるグループから1人を無作為に選出するため」とのことです。依頼が承諾されると研究チームは協力者の呼気アルコールテストを行い、正確な酩酊レベルを記録。その後協力者に対して以下の4つの質問を行うという調査方法になっています。1:あなたは今どれくらい酔っていますか?1(全くの冷静)~10(完全に泥酔)で答えて下さい。2:今夜はどれくらい飲みましたか?1(全然飲んでいない)~10(完全に極限まで)で答えて下さい。3:もし毎週末に今夜と同じくらい酔っ払ったら今後15年でどれくらい健康に不調を来すと思いますか?1(絶対に不調を来さない)~10(確実に不調を来す)で答えて下さい。4:もし毎週末に今夜と同じくらい酔っ払ったら今後15年でどれくらいの可能性で肝硬変になると思いますか?1(絶対にならない)~10(確実になる)で答えて下さい。深夜にクラブやバーで楽しく遊んでいたら、突如として研究員が上記のような質問をしてくるという状況はまるで冗談のようですが、呼気アルコールテストの結果と質問の回答を比較した結果、「お酒の席」で飲んでいる人々は実際に血中アルコール濃度を正確に把握しておらず、回りの人々の状態に基づいて「もっと飲むかどうか」という判断を下していることが判明しました。今回の調査で酔っ払っている人は周囲の人を見て自分の状態を過小評価してしまう可能性が高いことがわかっています。パーティーで泥酔してブラックアウトした人を見たことがある人もいると思いますが、その時に酔いが覚めて「自分は冷静だ」と感じたことがあるはず。このことから人々は「最も酔っ払っている人」を基準にするのではなく、「最も酔っ払っていない人」を見て自分の状態を認識する傾向があることがわかっています。この傾向から「お酒の席」で全員がお酒を飲んでいると「最も酔っ払っていない人」の最低値がどんどん上がっていくため、飲み過ぎる人が増えてしまいます。そのためお酒を飲まないハンドルキーパーのような人を増やせば、飲み過ぎを抑制できる可能性があると研究チームは予想しています。

title:  BMWが変形して人型ロボットになるリアルトランスフォーマー「Letrons」のムービー
link:   http://gigazine.net/news/20161001-real-transformer/
text:   映画「トランスフォーマー」のように何の変哲もないBMWが変形しまくってロボットになる様子を収めた驚きのムービーをYouTubeで見ることができます。ピカピカのBMWが登場。ススーっと徐行で進んでいきます。自動車が進んだ先にいた男性の手にはコントローラーが握られており、どうやら車内は無人で運転している様子。すると突如としてドアがウイーンと展開し始め……車体が持ち上がり始めました。二足歩行のロボットへと変形していきます。ボンネットからは頭部も出現。足の間から煙を出したり……腕や指を動かしたりできるようです。一通りアクションを披露すると、また車体が下がり始めました。あっという間に元通りのBMWに。そのまま元の場所まで戻っていきました。なお、この自動車はトルコのLetvisionという会社が開発した「Letrons」という製品で、価格は公開されていないものの、消費者向けに販売することも可能とのこと。Letronsはコントローラーで操作して移動させたり、変形させたりすることが可能ですが、人間が乗って運転することはできないそうです。ほかにも異なる自動車をベースにした4種類のリアルトランスフォーマーも開発されています。Letrons

title:  スペイン統治時代の名残りが感じられるフィリピン・バコロドの名士の邸宅跡「The Ruins」に行ってきました
link:   http://gigazine.net/news/20161001-ruins/
text:   フィリピンでも治安の良さで知られる田舎町バコロド。そのバコロドで最も有名で地元の人の誇りにもなっている観光スポットが、かつてのサトウキビ農園の大地主が建てた邸宅の跡「The Ruins(ルインズ)」です。Ruinsは300年以上も続いたスペイン統治時代を感じさせる建物でした。バコロド市内のメインストリートであるラクソン通りから自動車で約20分ほど東に行ったところにRuinsがあります。道中はサトウキビ畑が広がる農園なので、荷台の付いたバイク(トライシクル)で行くのもアリ。入場料は大人が95ペソ(約200円)入り口から入った一本道を隔てると……広々とした公園が広がっています。手入れの行き届いた芝生はとても爽快。北側には石でできたモニュメントがありました。石の柱の上に大きな石が乗っかっています。なお、モニュメント裏は公衆トイレになっていました。モニュメントから見た光景。西日が差す光景もなかなかです。公園には小川も流れています。小川のそばにあるベンチはスピーカー付き。音楽を聴きながらのんびりくつろぐのも良さそうです。公園をあとにして一本道を歩くと、大きな建物が見えてきました。入り口はこんな感じ。建物の前に、入り口のアイスクリームが気になります。フレーバーは3種類。定番のマンゴーアイスはかなり美味。アイスクリーム以外にもフランクフルトやサンドウィッチなどもありました。アイスクリームで一息ついたらいよいよRuinsへ。これがバコロドの名産であるサトウキビ農園を取り仕切っていた地元有数の名士Don Mariano Ledesma Lacson氏が建てた大邸宅跡「Ruins」階段を上ると……2階への立ち入りは禁止に。老朽化のため2階へは上がれないようになっています。広々とした大邸宅はコンクリート造り。左が主のMariano氏で右が妻のMaria Braga氏。残念ながら、Maria氏はこの大邸宅が完成する前に亡くなってしまったとのこと。イタリア建築のデザインを取り込んだ大邸宅は、アメリカ統治や第二次世界大戦中に旧日本軍の攻撃を受けたりしたため、破壊されてしまいました。しかし、最高級グレードのコンクリートで作られた建物は戦禍をくぐり抜け、決して朽ち果てることはありませんでした。戦後、大補修を経て、現在はバコロドを代表する名所として生まれ変わっています。広間では、地元では人気のテレビキャスターRoger Lucero氏による軽妙な語り口の英語による名物ガイドに、観光客が釘付けになっていました。館内には主たちの衣装や……かつての写真などが飾られていました。ちなみに観光地らしくお土産コーナーも充実しています。Ruinsは夜にライトアップされます。地元の人は夕暮れから夜にかけてのRuinsが一番美しいと言っていました。美しい空間はデートに場所としても人気。バコロドを訪れたからには外すことのできないスポットとなっていました。

リンクタイトルにまだ不備があるが、だいたい上手くいってる。 これでGIGAZINEの記事がDBに保存することができた。

GIGAZINEのRSSをPythonで読み込む

毎日読んでいるGIGAZINEをなんとかもっと効率的に読めないかと常日頃思ってる。 とりあえず、PythonRSSだけでも読み込んでみようとした記録を書いておく。

feedparserでRSSを取得

feedparserを使うとRSSから記事を取得してくれる。 各記事については、次のメタデータが格納されている。記事の本文はないが冒頭(summary)はある。

  • 'summary_detail'
  • 'updated_parsed'
  • 'links'
  • 'title'
  • 'tags'
  • 'updated'
  • 'summary'
  • 'guidislink'
  • 'title_detail'
  • 'link'
  • 'id'

feedparser.parse()にrssのリンクを渡すとrssを取得してくれる。 今回は、更新日(updated)、タイトル(title)、リンク(link)だけ取得した。 またidはlinkと同じだったため、リンクのラストパスをidとした。

def get_rss(self):
    rss_url = 'http://feed.rssad.jp/rss/gigazine/rss_2.0'
    articles = feedparser.parse(rss_url)

    rss = []
    for e in articles.entries:
        dic = {
            'updated': e.updated,
            'title': e.title,
            'link': e.link,
            'id': e.link.split('/')[-2]
        }
        rss.append(dic)

    return rss

MongoDBにRSSを書き込み

呼び出すとコレクションを上書きするメソッド。 co.drop()を呼び出すことでコレクションがクリアされる。 取得したRSSをdictのlistのまま全て保存できる。

def save_db(self, data):
    client = pymongo.MongoClient('localhost', 27017)
    db = client['my_database']
    co = db['my_collection']
    co.drop()

    co.insert(data)

MongoDBからRSSを読み込み

RSSを後で加工する予定なので、 保存したRSSを再度dictのlistの形式で呼び出すメソッドも書いた。

def read_db(self):
    client = pymongo.MongoClient('localhost', 27017)
    db = client['my_database']
    co = db['my_collection']

    data = [d for d in co.find()]

    return data

まとめ

3つのメソッドを順に実行すると、最新のGIGAZINERSSがDBに保存されていることを確認できる。

# RSSを取得
rss = self.get_rss()

# RSSをDBに書き込み
self.save_db(rss)

# RSSをDBから読み込み
rss = self.read_db()
for article in rss:
    print article['updated']
    print article['title']
    print article['link']
    print article['id']
    print

以下が結果。上手く行った。

2016-09-28T23:00:00+09:00
ただ起こしてくれるだけじゃなく早起きの習慣を体に叩き込んでくれるスマート目覚まし時計「Kello」
http://gigazine.net/news/20160928-kello/
20160928-kello

2016-09-28T22:00:00+09:00
「ごはんですよ」に柚子こしょうを加えた「柚子とうがらしのり」を炊きたてご飯や焼き鳥に付けて食べてみた
http://gigazine.net/news/20160928-momoya-yuzutogarashi/
20160928-momoya-yuzutogarashi

2016-09-28T21:00:00+09:00
リンカーンからトランプまで共和党は160年かけてどのように変化してきたのか?
http://gigazine.net/news/20160928-republican-party-lincoln-trump/
20160928-republican-party-lincoln-trump

...