アイテム埋め込みの正規化が推薦頻度に与える影響を調べてみた

サムネイル

はじめに

この記事はDMMグループ Advent Calendar 2025の11日目の記事です。

はじめまして。レコメンドチームで機械学習エンジニアをしている25新卒の田中です。

入社して初めてレコメンドモデルを開発するようになり、絶賛勉強の日々を送っています。
今回は色々学んだ中で抱いた「アイテムが推薦される頻度は正規化でどう変わるか」について深掘りしてみたいと思います。

背景: 正規化の有無で内積ベースの類似度は変わる → レコメンド結果はどう変わる?

良いレコメンドモデルを作るためには、予測精度だけでなく、カバレッジなど複数の指標を考慮する必要があります1

私も業務でモデルを開発する際には、複数の指標を使い、性能劣化が起きていないかビジネスサイドの要件を満たせているか確認しています2

そのようにモデルの評価をしていると、正規化の有無でアイテムごとの推薦頻度(レコメンド結果に表示される回数)の分布が、大きく変わってしまうことに気づきました。
モデルの validation の精度に大きな差はないにも関わらず、一方ではアイテムが平等に推薦され、一方では一部アイテムがほとんど推薦に出てこないのです。

これはレコメンドの品質に影響するため、深掘りしてみることにしました。

DMM のレコメンド - Two-Tower モデルによるレコメンド

前提として、DMM では Two-Tower モデルをメインのアルゴリズムとして採用しています。

Two-Tower モデルはユーザーの埋め込みモデルと、アイテムの埋め込みモデルからなるモデルです。

例えば、関連商品を推薦する場合、学習できたアイテムの埋め込みモデルを用いて、アイテムの埋め込みベクトルを作成し、ベクトル同士の類似度が高いものを選択するといったことを行います。
その後、類似度の高いものをそのまま表示するか、別のモデルで並び替えを行うことで表示するアイテムが決まります。

より詳細に、Two-Tower モデルについて知りたい方は、"TensorFlow Recommenders のチュートリアル"を参照ください。 DMM のレコメンドについて知りたい方は、 "DMMのあちこちをパーソナライズする推薦システム""DMM TVにおけるマイクロバッチを用いたニアリアルタイムレコメンドシステムの導入事例" を参照ください。

ベクトルの長さは推薦頻度に影響する

先述の通り、アイテムの埋め込みベクトル間の類似度を元にレコメンドアイテムを選ぶため、埋め込みベクトルの性質がレコメンド結果に大きく影響を及ぼします。

冒頭で疑問に思ったケースでは、類似度の計算にベクトル同士の内積3を使い、ベクトルの後処理としてL2正規化を適用していました。 内積では、ノルムの大きいアイテムは内積の値が大きくなりやすく、様々なアイテムのレコメンド結果に登場しやすくなります。 一方で、L2正規化を導入すると全てのアイテムのノルムが1.0に揃います。 そのため、L2正規化がない場合は、ノルムの大きなアイテムが頻繁に推薦され、相対的に他のアイテムが推薦されにくくなります。 対照的に、L2正規化がある場合は、各アイテムはより均等に推薦されるようになります。

埋め込みベクトルのノルムの意味について、自然言語処理分野では、 "単語ベクトルの長さは意味の強さを表す" 4といった性質が知られています。

一方で、レコメンドモデルにおいて、正規化の有無とノルムの関係を分析している文献は少なかったため、単純なモデルで検証してみることにしました。

単純なモデルで影響を調べてみた

以下の内容でモデルを学習させて、埋め込みベクトルのノルムと推薦頻度の回数の関係を比較しました。

目的はあくまで、正規化やモデルの変更によって、埋め込みベクトルがどう変わるかを簡単に確認することです。
そのため、モデルや特徴量は最小限にしたうえで、精度向上のためのチューニングもほとんど行っていません。

また、業務で使用しているデータは公開できないため、公開されているデータを使用しました。

実験設定

  • 使用ライブラリ: TensorFlow Recommenders (以下 tfrs)
  • 使用データ: MovieLens 25M Dataset
  • モデル (詳細は後述)
    • 学習: 簡単な Two-Tower モデル + 正規化など簡単な変更を3パターン, 埋め込みの次元数は32固定
    • 推論: item2item で tfrs の BruteForce による近傍探索で20件取得
  • 特徴量: movie_id, user_id のみ
  • 目的変数: ユーザによる5段階の rating 予測タスクと、バッチ内負例サンプリングによる Retrieval タスク
  • 精度評価: train, valid を 8:2 で分けて評価
  • その他設定:
    • optimizer: AdaGrad
    • エポック数: 10
    • バッチサイズ: 4096
  • チューニング内容: learning rate を 0.1, 0.01, 0.001, 0.0001 の 4パターン試行し valid のスコアが良いものを採用

Two-Tower モデルのコードは以下の通りです。

class BaseItemLayer(ItemLayer):
    def __init__(self, item_id_vocab: list[str], embedding_dim: int):
        super().__init__(item_id_vocab, embedding_dim)

        self.item_id_embeddings = tf.keras.Sequential([
            tf.keras.layers.StringLookup(
                vocabulary=list(item_id_vocab),
                mask_token=None
            ),
            tf.keras.layers.Embedding(
                input_dim=len(item_id_vocab) + 1,
                output_dim=embedding_dim
            )
        ])

    def call(self, item_ids):
        return self.item_id_embeddings(item_ids)

class UserLayer(tf.keras.layers.Layer):
    def __init__(self, user_id_vocab: list[str], embedding_dim: int):
        super().__init__()

        self.user_id_embeddings = tf.keras.Sequential([
            tf.keras.layers.StringLookup(
                vocabulary=list(user_id_vocab),
                mask_token=None
            ),
            tf.keras.layers.Embedding(
                input_dim=len(user_id_vocab) + 1,
                output_dim=embedding_dim
            )
        ])

    def call(self, user_ids):
        return self.user_id_embeddings(user_ids)

class I2ITrainModel(tfrs.Model):
    def __init__(
        self,
        item_layer: ItemLayer,
        user_layer: UserLayer,
        rating_task: tfrs.tasks.Ranking,
        retrieval_task: tfrs.tasks.Retrieval,
        rating_weight: float, # 今回は 1.0
        retrieval_weight: float, # 今回は 1.0
        use_candidate_sampling_prob: bool,
    ):

        super().__init__()

        self.item_layer = item_layer
        self.user_layer = user_layer
        self.rating_task = rating_task
        self.retrieval_task = retrieval_task
        self.rating_weight = rating_weight
        self.retrieval_weight = retrieval_weight
        self.use_candidate_sampling_prob = use_candidate_sampling_prob

        self.rating_model = tf.keras.Sequential([
            tf.keras.layers.Dense(256, activation="relu"),
            tf.keras.layers.Dense(128, activation="relu"),
            tf.keras.layers.Dense(1)
        ])

    def call(self, inputs: I2ITrainModelInputs, training=False) -> I2ITrainModelOutputs:
        user_embeddings = self.user_layer(inputs["user_id"])
        item_embeddings = self.item_layer(inputs["item_id"])
        rating_predictions = self.rating_model(
            tf.concat([user_embeddings, item_embeddings], axis=1),
            training=training
        )
        return {
            "user_embeddings": user_embeddings,
            "item_embeddings": item_embeddings,
            "rating_predictions": rating_predictions
        }

    def compute_loss(self, inputs: I2ITrainModelInputs, training=False) -> tf.Tensor:
        outputs = self(
            inputs, training=training
        )

        rating_loss = self.rating_task(
            labels=inputs["rating"],
            predictions=outputs["rating_predictions"]
        )

        if self.use_candidate_sampling_prob:
            retrieval_loss = self.retrieval_task(
                query_embeddings=outputs["user_embeddings"],
                candidate_embeddings=outputs["item_embeddings"],
                candidate_sampling_probability=inputs.get("candidate_sampling_probability", None)
            )
        else:
            retrieval_loss = self.retrieval_task(
                query_embeddings=outputs["user_embeddings"],
                candidate_embeddings=outputs["item_embeddings"]
            )

        weighted_rating_loss = self.rating_weight * rating_loss
        weighted_retrieval_loss = self.retrieval_weight * retrieval_loss
        total_loss = weighted_rating_loss + weighted_retrieval_loss

        return total_loss

比較するモデル

今回比較対象として、下記の4つのモデルを試しました。 本来の検証目的である L2正規化に加えて、ノルムに影響があるかもしれない、optimizer やサンプルバイアスの補正の影響も調べてみました。

  • base-model: 前述のデフォルト設定のモデル
  • l2norm-model: item layer の出力に L2正規化を適用したモデル5
  • adam-model: optimizer を Adam に変更したモデル
  • samplingprob-model: サンプリングバイアスの補正をしたモデル

サンプリングバイアスの補正は、tfrs の Retrieval の引数である candidate_sampling_probability を用いて行いました。 tfrs の Retrieval タスクは、バッチ内に存在する別のユーザとペアになっているアイテムを負例として採用しているため、人気アイテムが負例として登場しやすいというバイアスがあります。candidate_sampling_probability はアイテムの出現頻度に基づいた定数を学習時の類似度に足しこむことで、不人気アイテムを出にくくし、人気アイテムを出やすくできます。

結果: アイテムの頻度と埋め込みベクトルについて可視化

はじめに、今回学習したモデルの精度は下図のとおりです。samplingprob-model のみ recall が低いといった結果になりました。これは、Retrieval タスクの性質上、出現頻度の高いアイテムが負例になりやすいというバイアスを補正した結果、人気作品が出やすくマイナー作品の Recall が下がったからだと考えられます。

train,validationの精度 学習したモデルの精度

モデルの学習が確認できたので、アイテム埋め込みの集計と item2item 推薦の推薦結果を生成します。

アイテムの登場頻度

各モデルの結果を可視化する前に、学習データ中のアイテムの登場頻度を確認します。 下図は、アイテムの登場頻度をヒストグラム(x軸は対数目盛り)にしたものです。

学習データ中のアイテム登場頻度 学習データ中のアイテム登場頻度

登場回数の少ないアイテムが多く、パレート分布のようにロングテールの形状になっています。 モデルが学習データに忠実に学習した場合、推薦結果の分布も似た形状になることが予想されます。

各モデルについて可視化

base-model

base-model のレコメンド結果のアイテム登場頻度は下図の通りです。

base-model のレコメンド結果のアイテム登場頻度 base-model のレコメンド結果のアイテム登場頻度

学習データでのアイテム登場頻度に比べると、101〜102が少し膨らんでいます。 また、カバレッジは 65.3% です。

アイテムの埋め込みのノルムの分布は下図です。

base-model のアイテム埋め込みベクトルのノルム分布 base-model のアイテム埋め込みベクトルのノルム分布

2前後がピークですが、4〜5のところも少し膨らんでいます。

頻度とノルムのペアプロットは下図です。

base-model の学習データ内の登場頻度・推薦結果内の登場頻度・ノルムのペアプロット base-model の学習データ内の登場頻度・推薦結果内の登場頻度・ノルムのペアプロット

学習データ内の登場頻度とベクトルのノルム間、推薦頻度とノルム間には相関が見られます。 一方で、学習データ内の頻度と推薦頻度に相関は見られません。

最後に、PCA と t-SNE で可視化を行ったものが下図です。

base-model の埋め込みベクトルの PCA (寄与率) base-model の埋め込みベクトルの PCA (寄与率)

base-model の埋め込みベクトルの2Dマップ (PCA) base-model の埋め込みベクトルの2Dマップ (PCA)

base-model の埋め込みベクトルの2Dマップ (t-SNE) base-model の埋め込みベクトルの2Dマップ (t-SNE)

PCA では分かりにくいですが、t-SNE では学習データでの登場頻度の高いデータが黄色で、ある程度まとまった位置にあるのが確認できます。

l2norm-model

l2norm-model のレコメンド結果のアイテム登場頻度は下図の通りです。

l2norm-model のレコメンド結果のアイテム登場頻度 l2norm-model のレコメンド結果のアイテム登場頻度

10前後がピークの山からいくつか飛び出しているような形になっており、学習データの分布とは大きく異なっています。 また、カバレッジは 100% で、学習データに含まれているアイテム全てが、1回以上推薦されています。

アイテムの埋め込みのノルムは、正規化されているため、すべて1.0です(プロットは割愛)。 ただし、厳密には計算誤差があるため完全に同じ値ではないです。

ペアプロットは下図です。

l2norm-model の学習データ内の登場頻度・推薦結果内の登場頻度・ノルムのペアプロット l2norm-model の学習データ内の登場頻度・推薦結果内の登場頻度・ノルムのペアプロット

ノルムが固定というのもありますが、学習データでの頻度と推薦頻度の間は無相関になっています。

最後に、PCA と t-SNE で可視化を行ったものが下図です。

l2norm-model の埋め込みベクトルの PCA (寄与率) l2norm-model の埋め込みベクトルの PCA (寄与率)

l2norm-model の埋め込みベクトルの2Dマップ (PCA) l2norm-model の埋め込みベクトルの2Dマップ (PCA)

l2norm-model の埋め込みベクトルの2Dマップ (t-SNE) l2norm-model の埋め込みベクトルの2Dマップ (t-SNE)

PCA の第1主成分が学習データでの登場頻度と対応している風に見えます。t-SNE は、中央付近にも明るい点があるくらいで、base-model と大きな差はありません。

adam-model

adam-model のレコメンド結果のアイテム登場頻度は下図の通りです。

adam-model のレコメンド結果のアイテム登場頻度 adam-model のレコメンド結果のアイテム登場頻度

基本的な形は base-model と似ている一方で、101〜102の部分に小さな山ができています。 また、カバレッジは 49.8% で、学習データのうちの半分程度が推薦に登場しています。

アイテムの埋め込みのノルムの分布は下図です。

adam-model のアイテム埋め込みベクトルのノルム分布 adam-model のアイテム埋め込みベクトルのノルム分布

base-model に比べて、1〜2の部分に偏っており、ノルムの小さいベクトルが多いです。

頻度とノルムのペアプロットは下図です。

adam-model の学習データ内の登場頻度・推薦結果内の登場頻度・ノルムのペアプロット adam-model の学習データ内の登場頻度・推薦結果内の登場頻度・ノルムのペアプロット

base-model と同様の傾向で、 学習データでの登場頻度とベクトルのノルム間、推薦頻度とノルム間には相関が見られます。 一方で、学習データでの頻度と推薦頻度に相関は見られません。 また、学習データの登場頻度が一定を超えると、逆にベクトルのノルムが小さくなっています。

最後に、PCA と t-SNE で可視化を行ったものが下図です。

adam-model の埋め込みベクトルの PCA (寄与率) adam-model の埋め込みベクトルの PCA (寄与率)

adam-model の埋め込みベクトルの2Dマップ (PCA) adam-model の埋め込みベクトルの2Dマップ (PCA)

adam-model の埋め込みベクトルの2Dマップ (t-SNE) adam-model の埋め込みベクトルの2Dマップ (t-SNE)

base-model と同じような分布が見られます。

samplingprob-model

samplingprob-model のレコメンド結果のアイテム登場頻度は下図の通りです。

samplingprob-model のレコメンド結果のアイテム登場頻度 samplingprob-model のレコメンド結果のアイテム登場頻度

形状としては、base-model と同様ですが、カバレッジが 36.3% と過半数のアイテムが推薦されない状態になっています。

アイテムの埋め込みのノルムの分布は下図です。

samplingprob-model のアイテム埋め込みベクトルのノルム分布 samplingprob-model のアイテム埋め込みベクトルのノルム分布

base-model の分布からさらに、2前後の山を尖らせたような形をしています。

ペアプロットは下図です。

samplingprob-model の学習データ内の登場頻度・推薦結果内の登場頻度・ノルムのペアプロット samplingprob-model の学習データ内の登場頻度・推薦結果内の登場頻度・ノルムのペアプロット

こちらも base-model と同様の傾向ですが、学習データの登場頻度とノルムの相関がより大きくなっています。

最後に、PCA と t-SNE で可視化を行ったものが下図です。

samplingprob-model の埋め込みベクトルの PCA (寄与率) samplingprob-model の埋め込みベクトルの PCA (寄与率)

samplingprob-model の埋め込みベクトルの2Dマップ (PCA) samplingprob-model の埋め込みベクトルの2Dマップ (PCA)

samplingprob-model の埋め込みベクトルの2Dマップ (t-SNE) samplingprob-model の埋め込みベクトルの2Dマップ (t-SNE)

PCA で特に顕著ですが、登場頻度の多いアイテムの配置が分かりやすくなっています。

考察: ベクトルの長さは推薦頻度に影響する

ノルムのばらつきが小さいほどカバレッジは上昇する

下表のように、ノルムの分散が小さいモデルほど、高いカバレッジを示しています。

model name カバレッジ ノルムの分散
l2norm-model 100% 0.0
base-model 65.3% 0.99
adam-model 49.8% 1.3
samplingprob-model 36.3% 1.6

先ほどのプロットを見ても、一部のアイテムのノルムのみ大きい場合は、カバレッジが小さくなっています。

類似度の計算方法からも、ノルムの偏りがレコメンドの多様性に影響があると考えられます。 そのため、L2正規化などの手法を使って、ノルムを補正することで、レコメンドの多様性がコントロールできそうです。

サンプリングバイアスを考慮すると、人気作品が出やすくなる

samplingprob-model は、Retrieval タスクが持つサンプリングバイアスを考慮したモデルです。 Retrieval タスクには登場頻度の多いアイテムが負例になりやすいというバイアスがあるため、人気アイテムが推薦結果に出づらくなります。

実際 samplingprob-model は、PCA による可視化から分かるとおり、学習データでのアイテムの登場頻度をよく学習していそうです。 これは、学習データでの登場頻度とベクトルのノルムの相関が 0.439 と今回のモデルでは最大となっていることからも分かります。

また、マイナーアイテムのノルムが小さくなったため、推薦結果に登場しづらくなり、カバレッジが大きく悪化したと考えられます。

ベクトルの長さは推薦頻度に影響する

今回学習させた4パターンのモデルは、samplingprob-model を除けば、train, valid の精度に大差ありませんでした。しかし、カバレッジや登場頻度、埋め込みベクトルの可視化を行うことで、それぞれのモデルが全然違った特徴を持つことが分かりました。

L2正規化を行うことで、推薦頻度の分布は大きく変わりますし、そうでないモデルでは、ノルムの大きさと推薦頻度には相関がありました。

はじめの問いである、ベクトルの長さ(ノルム)は推薦頻度に影響を及ぼすかについては、影響があると言えます。

ただし、L2正規化を行った場合でも、PCA や t-SNE 上で人気作品の偏りが見られたため、ノルムがそのまま推薦頻度や人気の情報と結びついているというわけではないと考えられます。

おわりに

最後までお読みいただき、ありがとうございました!

本記事では、業務中に抱いた疑問について、公開されているデータセットを用いて単純なケースで検証しました。

validation の精度評価だけがモデルの良し悪しじゃないというのは、実際に業務で機械学習モデルを開発しなければ実感できないことでした。

まだまだ勉強中の身ですが、この指標だけ見ておけば良いといった正解がないことは、難しくもあり面白い部分だと思っています。

このような業務での検索レコメンドの話に興味のある方は、DMM データサイエンスグループが不定期で開催している Strategic Search & Recommendation Meetup でお話ししましょう!参加お待ちしております!

最後に、DMM データサイエンスグループでは一緒に働いてくれる仲間を募集しています!ご興味のある方は、ぜひ下記の募集ページをご確認ください!

dmm-corp.com


  1. 多様性や人気バイアスなどを考慮したレコメンドに関する資料として "クリック率を最大化しない推薦システム"などがあります。
  2. DMM における検索レコメンドの評価指標とビジネス KPI に関する課題については、PdM を務めている西潟さんの記事(コンテンツ配信サービスを成功に導く検索レコメンドの作り方 導入編)をご覧ください。
  3. 内積は、使用ライブラリである TensorFlow Recommenders が類似度の計算方法として採用している方法になります。(類似度スコアを計算する関数の実装リンク)
  4. 大山百々勢, 横井祥, 下平英寿. 単語ベクトルの長さは意味の強さを表す. 言語処理学会第28回年次大会(NLP2022)
  5. なお、今回はアイテムの埋め込みベクトルについて考察することが目的なので、L2正規化は item layer のみに適用しています。