Ml-tips

【LightGBM】ランキング学習(lambdarank)で検索結果を並べ替える

はじめに

回帰は「1件ごとの数値」を、分類は「1件ごとのクラス」を予測します。一方で**ランキング学習(Learning to Rank)**は、「クエリ(検索語など)ごとに、候補を関連度の高い順に並べる」ことを学習します。検索結果や推薦の並び順を決める、という場面のためのタスクです。

回帰と何が違うのでしょうか。検索では、各文書の関連度を正確な数値で当てる必要はありません。上位に良い文書が来る「順序」さえ合っていればよいのです。回帰は絶対値を合わせようとして、順序に影響しない誤差にも労力を使います。ランキング学習は順序そのものを最適化し、しかも上位ほど重視します(利用者は上のほうしか見ないため)。

LightGBMはobjective="lambdarank"でこれを扱えます。この記事では、説明用の小さな検索データを自分で作り、「あるクエリに対して文書を関連度順に並べる」ところまでを動かします。

データを用意する

イメージしやすいように、中身の見える小さな検索データを合成します。文書は実際のタイトルを持ち、特徴量は意味のあるものにします。

  • q_match … クエリの意図と文書のトピックが一致するか(0/1)
  • popularity … 文書の人気度(0〜1)
  • freshness … 文書の新しさ(0〜1)
  • relevance … 関連度(03

関連度は、2 × q_match + popularity + freshness + ノイズという値をクエリ内で0〜3に段階化して作ります。つまり「クエリに一致していて、人気があって、新しい」文書ほど関連度が高くなる、という素直な設計です。こう作ることで「正しい並び順」が分かっているデータになり、モデルがそれを学べるかを後で検証できます。

import numpy as np
import pandas as pd
import lightgbm as lgb
from sklearn.metrics import ndcg_score

catalog = [
    ("Python入門ガイド","python"), ("Pythonで始めるデータ分析","python"),
    ("Python公式チュートリアル","python"), ("Python高速化のコツ","python"),
    ("簡単パスタレシピ","cooking"), ("本格カレーの作り方","cooking"),
    ("作り置きおかず10選","cooking"), ("京都観光モデルコース","travel"),
    ("格安航空券の探し方","travel"), ("一人旅のすすめ","travel"),
    ("新NISAの始め方","finance"), ("家計簿アプリ比較","finance"),
]
titles = np.array([c[0] for c in catalog])
topics = np.array([c[1] for c in catalog])
uniq = ["python", "cooking", "travel", "finance"]

def make(n_query, seed):
    r = np.random.RandomState(seed)
    recs, group = [], []
    for _ in range(n_query):
        intent = r.choice(uniq)                              # このクエリの検索意図
        idx = r.choice(len(catalog), size=6, replace=False)  # 候補文書を6件
        q = []
        for i in idx:
            qm = int(topics[i] == intent)                    # トピック一致
            pop, fr = round(r.uniform(0,1),2), round(r.uniform(0,1),2)
            latent = 2.0*qm + 1.0*pop + 0.7*fr + r.randn()*0.3
            q.append([intent, titles[i], qm, pop, fr, latent])
        # クエリ内で latent を 0..3 の関連度に段階化
        lat = np.array([row[5] for row in q])
        grades = (lat.argsort().argsort() / 5 * 3).round().astype(int)
        for row, g in zip(q, grades):
            recs.append(row[:5] + [g])
        group.append(6)
    df = pd.DataFrame(recs, columns=["query","title","q_match","popularity","freshness","relevance"])
    return df, group

tr_df, tr_g = make(150, 1)     # 学習: 150クエリ × 6文書
te_df, te_g = make(50, 2)      # 評価: 50クエリ × 6文書

print("学習データ 先頭2クエリ(12行):")
print(tr_df.head(12).to_string(index=False))
学習データ 先頭2クエリ(12行):
  query           title  q_match  popularity  freshness  relevance
cooking Python公式チュートリアル        0        0.40       0.54          1
cooking    Python高速化のコツ        0        0.20       0.88          1
cooking        簡単パスタレシピ        1        0.03       0.67          2
cooking       新NISAの始め方        0        0.14       0.20          0
cooking Pythonで始めるデータ分析        0        0.80       0.97          2
cooking      作り置きおかず10選        1        0.88       0.89          3
 python      京都観光モデルコース        0        0.32       0.69          1
 python Python公式チュートリアル        1        0.79       0.10          3
 python     Python入門ガイド        1        0.45       0.91          2
 python        簡単パスタレシピ        0        0.13       0.02          0
 python Pythonで始めるデータ分析        1        0.68       0.21          2
 python       本格カレーの作り方        0        0.05       0.57          1

1つのクエリ(例:cooking)に6つの文書がぶら下がり、それぞれに関連度が付いています。cookingのクエリでは、一致していて人気も高い作り置きおかず10選が関連度3(最高)になっています。

group と特徴量

ランキング学習のデータで最重要なのがgroupです。「どの行が同じクエリに属し、互いに順位を競うのか」をLightGBMに教えます。今回は1クエリ6文書なので、group = [6, 6, 6, ...](クエリ数ぶん)になります。回帰・分類には無い、ランキング特有の情報です。

ここで注意したいのが、query列(検索意図)は特徴量に入れないという点です。学習に渡すのはq_matchpopularityfreshnessの3つだけです。

feats = ["q_match", "popularity", "freshness"]
Xtr, ytr = tr_df[feats].values, tr_df["relevance"].values
Xte, yte = te_df[feats].values, te_df["relevance"].values

「ではクエリの情報はどこで効くのか」というと、q_match(クエリと文書が一致するか)に埋め込まれていますq_matchは「この文書は、このクエリに合っているか」というクエリと文書の"関係"を表す特徴量です。ランキング学習では、クエリそのもの(IDやテキスト)ではなく、こうしたクエリ×文書の関係を数値化した特徴量を与えるのが基本です。実データでも「クエリ語が文書に何回出るか(BM25など)」といったマッチ度が特徴量になります。こうしておくと、学習に無かった新しいクエリにも対応できます(クエリを丸暗記するのではなく、関係の見方を学ぶため)。

lambdarankで学習する

objective="lambdarank"で学習します。lgb.Datasetgroupを渡すのがポイントです。

params = dict(objective="lambdarank", metric="ndcg", num_leaves=15,
              learning_rate=0.1, min_child_samples=20, verbose=-1)
model = lgb.train(params, lgb.Dataset(Xtr, ytr, group=tr_g), num_boost_round=200)

lambdarankが何をしているかにも触れておきます。勾配ブースティングは損失の勾配(微分)を使って木を育てますが、ランキングの指標(後述のNDCG)は並び替え=ソートに基づくため、そのままでは微分できません。lambdarankは「2つの文書を入れ替えたらNDCGがどれだけ変わるか」でペアごとの勾配(これがラムダ)を重み付けする、という工夫でこれを回避します。結果として、入れ替えの影響が大きいペア=上位の順位づけを重点的に学習します。

並べ替えの結果

学習したモデルで、テストの最初のクエリ(python)の候補を予測スコア順に並べてみます。

pred = model.predict(Xte)
sub = te_df.iloc[:te_g[0]].copy()
sub["score"] = pred[:te_g[0]].round(2)
print(sub.sort_values("score", ascending=False).to_string(index=False))
  query           title  q_match  popularity  freshness  relevance  score
 python     Python入門ガイド        1        0.18       0.79          2   4.26
 python Pythonで始めるデータ分析        1        0.22       0.35          2   3.59
 python    Python高速化のコツ        1        0.13       0.60          3   2.09
 python       新NISAの始め方        0        0.85       0.49          1  -2.07
 python        簡単パスタレシピ        0        0.62       0.53          1  -3.11
 python       本格カレーの作り方        0        0.43       0.10          0  -6.52

pythonというクエリに対して、Pythonの文書(q_match=1)が上位3件に、無関係な文書が下位に並びました。scoreはモデルが出した並べ替え用のスコアで、絶対値に意味はなく、クエリ内での大小関係だけが順位を表します(負の値でも問題ありません)。

NDCGによる評価

ランキングの良し悪しはNDCG@k(Normalized Discounted Cumulative Gain)で測ります。上位k件の並び順の良さを0〜1で表す指標で、次の性質があります。

  • 上位に関連度の高い文書があるほど高くなる
  • 順位が下がるほど寄与が割り引かれる(上位ほど重要)。だから1位に最も関連度の高い文書を置けているかが強く効く
  • 理想的な並び(関連度の降順)で1.0

でたらめに並べても0にはなりません(適当でも一部は上位に来るため)。今回は1クエリ6文書・関連度0〜3と規模が小さいので、ランダムでも0.5前後になります。これを基準に、学習後のモデルと比べます。

def ndcg_at(scores, k):
    out, st = [], 0
    for g in te_g:
        out.append(ndcg_score(yte[st:st+g].reshape(1,-1),
                              scores[st:st+g].reshape(1,-1), k=k))
        st += g
    return float(np.mean(out))

rng = np.random.RandomState(0)
rand = rng.randn(len(yte))
for k in [1, 3, 5]:
    print(f"NDCG@{k}: 学習後={ndcg_at(pred,k):.3f}  ランダム={ndcg_at(rand,k):.3f}")
NDCG@1: 学習後=0.887  ランダム=0.553
NDCG@3: 学習後=0.919  ランダム=0.626
NDCG@5: 学習後=0.937  ランダム=0.736

学習後はNDCG@10.887と、ほぼ理想(1.0)に近い並び順です。ランダム(0.553)を全てのkで明確に上回っており、クエリごとに候補を関連度順へ並べ替えられていることが数値でも確認できます。

注意点

  • groupの合計は行数と一致させ、同じクエリの行が連続して並んでいる必要があります。
  • 予測スコアは順序にのみ意味があります。クエリをまたいで大小を比較しても意味はありません。
  • 実データでは、文書は「クエリと文書のペアを数値化した特徴量」(マッチ度・文書長・タイトル一致など)で表されます。今回は説明のため、意味のわかる少数の特徴量で合成しました。
  • 関連度の段階ごとの重みはlabel_gainで調整できます(デフォルトは2^label - 1)。
  • 関連度を数値とみなした普通の回帰でも近いNDCGが出ることが多く、lambdarankが常に大きく勝つわけではありません。ランキング学習は「順序を直接最適化する枠組み」として理解しておくとよいです。

まとめ

  • ランキング学習はクエリごとに候補を関連度順に並べるタスク(回帰・分類とは別物)
  • データは特徴量X・関連度yに加えてgroup(各クエリの行数)が必要
  • クエリ自体は特徴量に入れず、q_matchのようなクエリ×文書の関係を表す特徴量で効かせる
  • objective="lambdarank"で学習し、予測スコアの大小でクエリ内を並べ替える
  • 評価はNDCG@k(上位重視の順位指標)。今回は学習後がランダムを明確に上回った

参考リンク