2016年4月23日

形態素解析を使ってPostgreSQLに保存された文章データから話題を抽出する

PythonやPL/Python、PostgreSQLを使ってデータ分析をIn-Database処理させるのがマイブームです。

今回は、データベース内に保存された文章のテキストデータから単語の出現頻度を使って話題になっているトピックを抽出する、という処理を行ってみます。
  • テキストを形態素解析する
  • 形態素解析した結果をJSONBで取得する
  • JSONBデータを対象に集計処理を行う
  • 上記すべてをサーバサイドで実行する
といったことをPostgreSQLを使って処理してみます。

■データの準備


今回も東京カレンダーの「東京女子図鑑」からの文章をサンプルとして使ってみます。
今回は、docidという主キーとテキストを値としてdoctextカラムに持つテーブルを作成し、そこにテキストを保存しておくようにします。今回のテキストは約4,000文字あります。
snaga=# \d docs
      Table "public.docs"
 Column  |  Type   | Modifiers
---------+---------+-----------
 docid   | integer | not null
 doctext | text    |
Indexes:
    "docs_pkey" PRIMARY KEY, btree (docid)

snaga=# SELECT docid, length(doctext) FROM docs;
 docid | length
-------+--------
     1 |   4423
(1 row)

snaga=# SELECT docid, substring(doctext,0,60) AS doctext FROM docs;
 docid |                                                       doctext
-------+---------------------------------------------------------------------------------------------------------------------
     1 | 20代後半頃から、同期が1人また1人と、会社を辞めていきました。辞める理由はいろいろありますが、病んでしまった子もいれ
(1 row)

snaga=#

■形態素解析を行うユーザ定義関数を作成する


まず、テキストを入力として受け取り、形態素解析した結果をJSONB型として返却するユーザ定義関数を作成します。

形態素解析には MeCab を、ユーザ定義関数には PL/Python を、PL/Python と MeCab のバインディングには Mecab-Python を使います。環境は以前のエントリと同様ですので、そちらを参考に準備してください。PostgreSQLのバージョンは9.5です。
MeCabのPythonバインディングについては、以下を参照してください。 ユーザ定義関数は以下のようになります。

CREATE OR REPLACE FUNCTION mecab_tokenize_jsonb(string text)
  RETURNS SETOF jsonb
AS $$
    import MeCab
    import json
    import plpy
    import sys

    def mecab_text2array(string):
        a = []
        m =  MeCab.Tagger("-Ochasen")

        """
        Mecabに渡すためにはunicodeではなくutf-8である必要がある。
        Mecabから戻ってきたらunicodeに戻す。

        また、Mecabはエンコード済みのutf-8文字列へのポインタを返すので、
        on-the-flyでutf-8に変換するのではなく、変数として保持しておく
        必要がある。(でないとメモリ領域がGCで回収されてデータが壊れる)

        参照:
        http://shogo82148.github.io/blog/2012/12/15/mecab-python/
        """
        enc_string = string
        node = m.parseToNode(enc_string)
        while node:
            n = {}
            n['surface'] = node.surface.decode('utf-8')
            n['feature'] = node.feature.decode('utf-8').split(",")
            n['cost'] = node.cost
            a.append(n)
            node = node.next
        return a

    for w in mecab_text2array(string):
        yield(json.dumps(w))

$$ LANGUAGE plpythonu;
ポイントは
  • MeCabの持っているデータ型をそのままJSONエレメントとして返却。
    • 「surface」はトークンにした文字列、「feature」はその文字列の属性情報。
  • jsonb型を行で返却する関数として定義。
あたりになります。

■文章を品詞に分解する


それでは doctext カラムに保存されているテキストを形態素解析してみます。

SELECT
  docid,
  mecab_tokenize_jsonb(doctext)
FROM
  docs;
mecab_tokenize_jsonb()の引数に doctext カラムを渡してSELECT文を実行すると、以下のようにトークンごとの情報が返却され、品詞の情報をJSONBで取得できるようになります。

 docid |                                                                   mecab_tokenize_jsonb
-------+-----------------------------------------------------------------------------------------------------------------------------------------------------------
     1 | {"cost": 0, "feature": ["BOS/EOS", "*", "*", "*", "*", "*", "*", "*", "*"], "surface": ""}
     1 | {"cost": 27956, "feature": ["名詞", "数", "*", "*", "*", "*", "*"], "surface": "20"}
     1 | {"cost": 28475, "feature": ["名詞", "接尾", "助数詞", "*", "*", "*", "代", "ダイ", "ダイ"], "surface": "代"}
     1 | {"cost": 33141, "feature": ["名詞", "副詞可能", "*", "*", "*", "*", "後半", "コウハン", "コーハン"], "surface": "後半"}
     1 | {"cost": 38173, "feature": ["名詞", "接尾", "副詞可能", "*", "*", "*", "頃", "ゴロ", "ゴロ"], "surface": "頃"}
     1 | {"cost": 37905, "feature": ["助詞", "格助詞", "一般", "*", "*", "*", "から", "カラ", "カラ"], "surface": "から"}
(...)
     1 | {"cost": 4436512, "feature": ["名詞", "代名詞", "一般", "*", "*", "*", "彼", "カレ", "カレ"], "surface": "彼"}
     1 | {"cost": 4441497, "feature": ["助詞", "並立助詞", "*", "*", "*", "*", "や", "ヤ", "ヤ"], "surface": "や"}
     1 | {"cost": 4436788, "feature": ["記号", "読点", "*", "*", "*", "*", "、", "、", "、"], "surface": "、"}
     1 | {"cost": 4440439, "feature": ["名詞", "一般", "*", "*", "*", "*", "上司", "ジョウシ", "ジョーシ"], "surface": "上司"}
     1 | {"cost": 4440813, "feature": ["助詞", "連体化", "*", "*", "*", "*", "の", "ノ", "ノ"], "surface": "の"}
     1 | {"cost": 4443900, "feature": ["名詞", "一般", "*", "*", "*", "*", "おかげ", "オカゲ", "オカゲ"], "surface": "おかげ"}
     1 | {"cost": 4444601, "feature": ["助詞", "格助詞", "一般", "*", "*", "*", "で", "デ", "デ"], "surface": "で"}
(...)
     1 | {"cost": 4543115, "feature": ["動詞", "自立", "*", "*", "五段・ワ行促音便", "連用形", "思う", "オモイ", "オモイ"], "surface": "思い"}
     1 | {"cost": 4540257, "feature": ["助動詞", "*", "*", "*", "特殊・マス", "基本形", "ます", "マス", "マス"], "surface": "ます"}
     1 | {"cost": 4537422, "feature": ["記号", "句点", "*", "*", "*", "*", "。", "。", "。"], "surface": "。"}
     1 | {"cost": 4535886, "feature": ["BOS/EOS", "*", "*", "*", "*", "*", "*", "*", "*"], "surface": ""}
(2718 rows)

■単語ごとの出現頻度を集計する


それでは、ここまでの結果を使って単語ごとの出現頻度を集計してみます。

JSONBのデータからキーを指定してエレメントを取り出すには「->」を使います。

json_obj->'key_name'

この場合はエレメントがJSONB型で返却されます(JSONBの内部に再帰的にアクセスしたい場合)。text型で取り出したい場合には「->>」を使います。

json_obj->>'key_name'

また、配列の要素へアクセスしたい場合には、「->0」のように添字で指定することで取得することができます。

json_obj->'array_name'->>0

詳細は以下のマニュアルを参照してください。
今回のデータは、JSONBのデータの中に surface をキーとして値にトークンの文字列が、feature をキーとして値に属性情報(品詞の種別など)が配列で入っています。

よって、JSONBからトークンと品詞種別を取り出し、この2つをGROUP BYのキーとしてCOUNTで出現頻度の集計をしてみます。

WITH doc AS (
SELECT
  docid,
  mecab_tokenize_jsonb(doctext) t
FROM
  docs
)
SELECT
  docid,
  t->>'surface' as surface,
  t->'feature'->>0 as feature,
  count(*)
FROM
  doc
GROUP BY
  docid,
  t->>'surface',
  t->'feature'->>0
ORDER BY
  4 DESC;

このクエリを実行して出現頻度順にトークンを並べると、以下のような結果が得られます。

 docid |           surface            | feature | count
-------+------------------------------+---------+-------
     1 | 、                           | 記号    |   175
     1 | の                           | 助詞    |   133
     1 | て                           | 助詞    |   104
     1 | 。                           | 記号    |   103
     1 | に                           | 助詞    |    81
     1 | た                           | 助動詞  |    73
     1 | が                           | 助詞    |    65
(...)
     1 | の                           | 名詞    |    14
     1 | 銀座                         | 名詞    |    14
     1 | 私                           | 名詞    |    13
     1 | なっ                         | 動詞    |    13
     1 | って                         | 助詞    |    13
(...)
     1 | 感覚                         | 名詞    |     1
     1 | 食べ                         | 動詞    |     1
     1 | あぁ                         | 感動詞  |     1
     1 | 出す                         | 動詞    |     1
     1 | 詰まっ                       | 動詞    |     1
(787 rows)


■出現している品詞の種類を調べる


先に集計したランキングを見ると、当たり前ですが記号や助詞の出現頻度が高く、このままではあまり面白い結果になりません。そのため、簡単な方法として品詞の種別でフィルタリングすることを考えてみます。

まずは、出現している品詞の種別の一覧を確認してみましょう。

WITH doc AS (
SELECT
  docid,
  mecab_tokenize_jsonb(doctext) t
FROM
  docs
)
SELECT
  DISTINCT t->'feature'->>0 as feature
FROM
  doc

品詞種別の一覧を見てみると、12種類の品詞が出てきているようです。

 feature
---------
 助動詞
 名詞
 接頭詞
 助詞
 形容詞
 副詞
 連体詞
 動詞
 感動詞
 BOS/EOS
 接続詞
 記号
(12 rows)

■品詞の種別を絞ってランキングを作成する


それでは、最後に「名詞」に絞って表示してみましょう。

以下のクエリでは名詞に絞った上で、ひらがな1文字または2文字の結果も省いています。

WITH doc AS (
SELECT
  docid,
  mecab_tokenize_jsonb(doctext) t
FROM
  docs
)
SELECT
  docid,
  t->>'surface' as surface,
  t->'feature'->>0 as feature,
  count(*)
FROM
  doc
WHERE
  t->'feature'->>0 IN ('名詞')
AND
  regexp_replace(t->>'surface', '^[あいうえおかきくけこさしすせそたちつてとなにぬねのはひふへほまみむめもやゆよわをんがぎぐげござじずぜぞだぢづでどばびぶべぼぱぴぷぺぽっゃゅょ]{1,2}$', '') <> ''
GROUP BY
  docid,
  t->>'surface',
  t->'feature'->>0
ORDER BY
  4 DESC;

このクエリを実行すると、以下の結果が得られます。

 docid |           surface            | feature | count
-------+------------------------------+---------+-------
     1 | 方                           | 名詞    |    15
     1 | 銀座                         | 名詞    |    14
     1 | 私                           | 名詞    |    13
     1 | 人                           | 名詞    |    11
     1 | 上司                         | 名詞    |    10
     1 | 彼                           | 名詞    |     9
     1 | 歳                           | 名詞    |     8
     1 | 女性                         | 名詞    |     8
     1 | 恵比寿                       | 名詞    |     7
     1 | 店                           | 名詞    |     7
     1 | 笑                           | 名詞    |     7
     1 | 同期                         | 名詞    |     6
     1 | 大人                         | 名詞    |     6
     1 | 女                           | 名詞    |     6
(...)
     1 | 最中                         | 名詞    |     3
     1 | 年上                         | 名詞    |     3
     1 | 桟敷                         | 名詞    |     3
     1 | 外資                         | 名詞    |     3
     1 | 1                            | 名詞    |     3
     1 | 理由                         | 名詞    |     3
     1 | 箱                           | 名詞    |     3
     1 | 前                           | 名詞    |     3
     1 | 秋田                         | 名詞    |     3
(...)
     1 | 子供                         | 名詞    |     1
     1 | 赤                           | 名詞    |     1
     1 | 幕間                         | 名詞    |     1
     1 | 確か                         | 名詞    |     1
     1 | ビーフ                       | 名詞    |     1
     1 | 700                          | 名詞    |     1
(433 rows)

このラインキングを見ると、どのようなトピックがどの程度話題になっているかを、おおまかに見てとることができます。

例えば、
  • 恵比寿より銀座の方が言及が多い(恵比寿から銀座に引っ越した話なので当然と言えば当然)
  • 彼より上司の方が言及が多い
  • 誰よりも自分自身(私)の話が多い
などといったことがこの集計から分かるようになります。

■まとめ


というわけで、今回はPostgreSQL内部に保存したテキストに自然言語処理を適用して、言及されている話題を抽出してみる方法を簡単にご紹介しました。

データベース内に保存されている数値データやコードなどの分析に加えて、自然言語処理によって通常の文章から情報を抽出できるようになると、データ分析がさらに楽しくなると思います。

より技術的に進んだ手法も応用できる領域もいろいろあると思いますので、興味のある方はぜひチャレンジしてみていただければと思います。

では、また。

0 件のコメント:

コメントを投稿