ナード戦隊データマン

データサイエンスを用いて悪と戦うぞ

文書分類モデルをLTRの特徴量にする

以前の記事で、LTRの概要を書きました。MicrosoftのLTRのデータセットでは、"Quality Score"という特徴量が用意されていて、おそらくコンテンツベースの特徴量だと思うので、ここでは文書分類モデルを特徴量として与える方法を書きます。

どうやってアノテーションするおつもり?

コンテンツベースの特徴量として、例えば「文書の有害性」などを判定するモデルを作成したいと思うでしょう。でも、手作業で文書を分類してアノテーションするのは相当苦労します。そこで、ヒューリスティックな方法で以下を採用します。

事前準備1: クロール済みデータをElasticsearch等でインデクシングしておく。 事前準備2: Quality Scoreを表すフィールドをインデクスのマッピングに追加。

ヒューリスティクス: 1. このクエリを投げると殆どの検索結果が「良いページ(or 悪いページ)」になるようなクエリを複数探す。 2. そのクエリで取得した文書で、良いページのクエリで出てきたものをTrue, 悪いページのクエリで出てきたものをFalseとして自動的にアノテーションする。

以下はそのコードの例です:

from elasticsearch import Elasticsearch
from tqdm import tqdm_notebook

def create_base_query(text, size=1000):
    baseQuery = {
        'size': int(size),
        'query':{
            'function_score':{'query': {"simple_query_string": {
                "query": text,"fields": ["title"],"default_operator": "and"
            }}}
        }
    }
    return baseQuery

es = Elasticsearch("192.168.88.85:9200")
good_queries = ["Wikipedia", "はてなキーワード", "ヘルスケア大学", "医療総合QLife", "ロイター", "MIT Tech Review"]
bad_queries = ["ログ速", "2ちゃんねる","気団ログ", "エロ", "キチガイ", "ネトウヨ", "サヨク", "fuck", "萌え"]

out = []
for q in tqdm_notebook(good_queries):
    query = create_base_query(text=q, size=3000)
    results = es.search(index="test_ltr", doc_type="page", body=query)
    for r in results['hits']['hits']:
        out.append((r['_source']['texts'], True))
        
for q in tqdm_notebook(bad_queries):
    query = create_base_query(text=q, size=3000)
    results = es.search(index="test_ltr", doc_type="page", body=query)
    for r in results['hits']['hits']:
        out.append((r['_source']['texts'], False))

モデルの訓練と評価

次に、前述のデータを使って、良質なページかどうかを分類するモデルを作ります。モデリングには色々方法があるため、ここではコード的にわかりやすい方法を採用します。(DNN + nnlm-ja Embedding)

ただし、"out"という変数は、前述のコードのものをそのまま使います。(jupyterなどを使ってください。)

import tensorflow as tf
import tensorflow_hub as hub
import numpy as np
import pandas as pd
from sklearn.utils import shuffle
from sklearn.metrics import roc_auc_score
from sklearn.model_selection import train_test_split

df = pd.DataFrame(out, columns=["texts", "label"])
df = shuffle(df)
X_train, X_test, y_train, y_test = train_test_split(df[["texts"]], df["label"])

train_input_fn = tf.estimator.inputs.pandas_input_fn(
    X_train, y_train, num_epochs=None, shuffle=True)

predict_test_input_fn = tf.estimator.inputs.pandas_input_fn(
    X_test, None, shuffle=False)

embedded_text_feature_column = hub.text_embedding_column(
    key="texts", 
    module_spec="https://tfhub.dev/google/nnlm-ja-dim128/1")

estimator = tf.estimator.DNNClassifier(
    hidden_units=[500, 100],
    feature_columns=[embedded_text_feature_column],
    n_classes=2,
    optimizer=tf.train.AdagradOptimizer(learning_rate=0.003), model_dir="model2/")

estimator.train(input_fn=train_input_fn, steps=5000)

y_pred = [x['class_ids'][0] > 0.0 for x in estimator.predict(predict_test_input_fn)]

print(roc_auc_score(y_test, y_pred))

出力: 0.9833079636964895

このデータセットでは汎化性能があることがわかります。(本当は、用意したデータとは全く別のデータでAUCを求めたほうが良いです。)

Elasticsearchにアップデート

この種の特徴量は、インデクシングの際に同時に追加することが難しいため、すでにインデクシングしてあるドキュメントに対して、updateする形で特徴量を追加します。

import pandas as pd
import tensorflow as tf
import tensorflow_hub as hub
from elasticsearch import Elasticsearch
from tqdm import tqdm

def get_esindex_maxid(indexname='test_ltr', host="192.168.88.85:9200"):
    es = Elasticsearch(host)
    esquery = {
        'query': {'match_all': {}},
        'aggs': {'max_pgid': {'max': {'field': 'page_id'}}}
    }
    ret = es.search(index = indexname, size = 0, doc_type = 'page', body = esquery)
    return int(ret['aggregations']['max_pgid']['value'])


def solve_score(es, model_dir="model2/"):
    embedded_text_feature_column = hub.text_embedding_column(
            key="texts",
            module_spec="https://tfhub.dev/google/nnlm-ja-dim128/1")
    
    estimator = tf.estimator.DNNClassifier(
        hidden_units=[500, 100],
        feature_columns=[embedded_text_feature_column],
        n_classes=2,
        optimizer=tf.train.AdagradOptimizer(learning_rate=0.003), model_dir=model_dir)

    MAXSIZE = get_esindex_maxid("test_ltr")
    SIZE = 100
    for i in tqdm(range(int(MAXSIZE/SIZE) + 1)):
        results = []
        for j in range(SIZE):
            idx = j+i*SIZE
            if idx > MAXSIZE:
                break
            try:
                data = es.get(index="test_ltr", doc_type="page", id=idx)
                if 'quality_score' in data['_source']:
                    continue
                results.append((idx, data['_source']['texts']))
            except:
                print(idx)
        if len(results) == 0:
            continue
        df = pd.DataFrame(results, columns=["id", "texts"])
        input_fn = tf.estimator.inputs.pandas_input_fn(df[['texts']], None, shuffle=False)
        probs = [x['probabilities'][0] for x in estimator.predict(input_fn)]
        for idx, prob in zip(df['id'].tolist(), probs):
            try:
                es.update(index="test_ltr", doc_type="page", id=idx, body={"doc":{"quality_score": float(prob)}})
            except:
                print(idx)

if __name__ == "__main__":
    es = Elasticsearch("192.168.88.85:9200")
    solve_score(es, model_dir="/home/shun/work/scripts/model2/")

おわりに

ここで述べた方法は、あくまでも「アノテーションが面倒なときの一つの妥協案」だと考えてください。もし、信頼できる「文書の有害性コーパス」みたいなものがあれば、そちらを使ったほうが良いでしょう。ただ、ここで述べた方法の場合、「不正にTopに出現させようとしてくるWebスパム」を排除するのにも使えます。WebスパムのURLをベースにアノテーションを作成すれば、Quality Scoreをスパム検出に使えます。

参考

[1] https://www.tensorflow.org/tutorials/text_classification_with_tf_hub