🏠 总目录📚 本教程 03 · 数据长什么样
📑 本页目录(点开跳转)

03 · 数据长什么样

20 分钟 | ⭐ 核心 | 🔨 有代码


🎯 一句话

推荐系统的原料只有一张表:「谁,在什么时候,对什么,做了什么」。所有算法都是在榨这张表。


📋 最原始的形态:行为日志

真实公司里,一切开始于这样一行日志:

user_id=10086  item_id=88231  action=click  timestamp=1753574400  scene=feed  device=iOS

亿万行这样的日志堆起来,就是推荐系统的全部原料。

它可以整理成一张交互表

user_id item_id 行为 时间
10086 88231 点击 2026-07-27 09:00
10086 88231 播放完成 2026-07-27 09:00
10086 77012 划走(1秒) 2026-07-27 09:01
10087 88231 点赞 2026-07-27 09:01

🔢 显式反馈 vs 隐式反馈(⭐ 必须分清)

这是新手最容易搞错、也最影响后果的一个概念。

显式反馈 Explicit隐式反馈 Implicit
是什么用户明确表达的偏好从行为里推断的偏好
长什么样 5 星评分
点赞 / 点踩
「不感兴趣」
点击、播放、停留时长
购买、加购、收藏
划走、快速跳过
好在哪✅ 信号干净、有正有负 ✅ 量大(1000 倍以上
坏在哪❌ 极其稀少(<1% 用户会评分) ❌ 有噪声、几乎没有真负样本

🔥 隐式反馈的核心难题:没有「负样本」

用户没点这个视频,是因为:

你分不清。

所以隐式反馈的世界里只有 「1 和 未知」,没有 「1 和 0」。这叫 One-Class 问题,直接决定了后面的算法设计(第 5 节的 BPR 就是为它发明的)。

⚠️ 实践中的第一原则:把「曝光了但没点」当负样本,而不是把「所有没交互的」当负样本。前者是真的看见了没兴趣,后者只是没机会。 但注意:这样训出的模型只在「已曝光分布」上准,这个偏差叫 曝光偏差 (Exposure Bias),第 12 节详谈。


📊 核心数据结构:用户-物品交互矩阵

把交互表摊平,就是这张矩阵——推荐算法的「元数据结构」

                物品 →
           i1   i2   i3   i4   i5   i6  ...  i100000000
用      u1 [ 5    ?    3    ?    ?    ?  ...      ?    ]
户   u2 [ ?    4    ?    ?    1    ?  ...      ?    ]
↓      u3 [ 2    ?    ?    5    ?    ?  ...      ?    ]
       u4 [ ?    ?    ?    ?    ?    4  ...      ?    ]
       ...
       u300000000

推荐 = 把这张表里的「?」填上,然后取每行最大的几个。

这一句话是矩阵分解、协同过滤、乃至大部分推荐算法的统一视角。

😱 但这张矩阵大得离谱,而且几乎全空

拿真实数据感受一下:

数据集 用户数 物品数 交互数 稀疏度
MovieLens 32M 20 万 8.7 万 3200 万 99.82% 是空的
淘宝级别 ~10 亿 ~10 亿 ~万亿 99.9999%+ 是空的

💡 人话:这张矩阵一百万个格子里可能只有一个有数。 所有推荐算法的本质,都是在跟这个「极端稀疏」搏斗。


🧱 除了交互,还有什么数据

类别 例子 用在哪
用户画像 年龄、性别、地域、注册时长、历史偏好标签 冷启动、精排特征
物品属性 类目、标签、作者、时长、发布时间、封面图、标题文本 内容召回、冷启动
上下文 时间、地点、设备、网络、当前页面 精排特征(周五晚 vs 周一早,推荐完全不同)
社交关系 关注、好友 社交召回
多模态 视频帧、音频、图像 Embedding 内容理解、冷启动

🔨 动手:摸一遍真实数据

用最经典的 MovieLens 数据集。下载只要 1MB(小版本)。

import pandas as pd
import zipfile, urllib.request, io

# 下载 MovieLens 最小版(100k 评分,1MB)
url = "https://files.grouplens.org/datasets/movielens/ml-latest-small.zip"
with urllib.request.urlopen(url) as r:
    z = zipfile.ZipFile(io.BytesIO(r.read()))
    ratings = pd.read_csv(z.open("ml-latest-small/ratings.csv"))
    movies  = pd.read_csv(z.open("ml-latest-small/movies.csv"))

print("=== 交互表长这样 ===")
print(ratings.head())
print(f"\n用户数:{ratings.userId.nunique()}")
print(f"物品数:{ratings.movieId.nunique()}")
print(f"交互数:{len(ratings)}")

# 算稀疏度 —— 感受一下有多空
density = len(ratings) / (ratings.userId.nunique() * ratings.movieId.nunique())
print(f"稀疏度:{(1-density)*100:.2f}% 的格子是空的")

print("\n=== 长尾分布:绝大多数物品几乎没人看 ===")
item_counts = ratings.movieId.value_counts()
print(f"最热门的电影被评了 {item_counts.iloc[0]} 次")
print(f"中位数电影只被评了 {item_counts.median():.0f} 次")
print(f"只被评过 1 次的电影占比:{(item_counts==1).mean()*100:.1f}%")

print("\n=== 前 20% 的物品占了多少交互量 ===")
top20 = item_counts.head(int(len(item_counts)*0.2)).sum()
print(f"{top20/len(ratings)*100:.1f}%")

你会看到的关键事实:稀疏度 98%+,前 20% 的物品吃掉 80%+ 的流量。 这就是长尾分布——推荐系统存在的意义,很大程度上就是把长尾里的好东西挖出来


📉 长尾:必须刻进脑子的一张图

交互次数
  ▲
  │ ██
  │ ██
  │ ██ ██
  │ ██ ██ ██
  │ ██ ██ ██ ██ ▄▄
  │ ██ ██ ██ ██ ██ ▄▄ ▄▄ ▄▄ ▄▄ ▂▂ ▂▂ ▂▂ ▂▂ ▂▂ ▂▂ ▂▂ ▂▂ ▂▂
  └────────────────────────────────────────────────────────► 物品(按热度排序)
    ↑热门(头部)          ↑腰部                  ↑长尾(占物品总数 80%+)

   编辑推荐/榜单           ← 这里 →           只有推荐系统能覆盖
   就够了                                      (人工运营不可能)

记住:热门内容不需要推荐系统,榜单就够了。推荐系统的价值全在腰部和长尾。


🕳️ 数据里的三个大坑(提前知道能救命)

坑 1:数据泄漏(Data Leakage)

用「未来」的数据训练,预测「过去」→ 离线指标好得离谱,上线暴死。

正确做法:按时间切分训练/测试集,不要随机切分。

# ❌ 错误:随机切分
train, test = train_test_split(ratings, test_size=0.2)

# ✅ 正确:按时间切分
ratings = ratings.sort_values("timestamp")
split = int(len(ratings) * 0.8)
train, test = ratings[:split], ratings[split:]

坑 2:幸存者偏差 / 曝光偏差

你的日志里只有「系统曾经推过的东西」。系统没推过的,你永远不知道用户会不会喜欢。 → 模型学到的其实是「上一版模型的偏好」,会自我强化。

缓解:留一小部分流量做随机曝光(探索),收集无偏数据。第 13 节详谈。

坑 3:位置偏差(Position Bias)

排第 1 位的东西点击率天然比第 10 位高,跟内容好坏无关。 如果直接用点击率训练,模型学的是「位置」不是「兴趣」。

缓解:把位置作为特征输入训练,预测时统一填成固定值(如位置 1)。


🔗 这一章连到哪里

去哪为什么
ML基础 05时间切分、数据泄漏的通用版本(含三条泄漏路径的图)
上线之后 04「预测时点真的拿得到这个值吗」—— 特征穿越的生产形态
Kaggle 01竞赛视角的验证集构造

✅ 检查点

  1. 显式反馈和隐式反馈的区别?哪个量更大?
  2. 隐式反馈为什么没有真正的负样本?实践中拿什么当负样本?
  3. 用户-物品矩阵大概有多稀疏?
  4. 为什么切分数据集要按时间而不能随机?
  5. 长尾分布是什么?推荐系统的价值在头部还是长尾?

👀 答案
  1. 显式反馈是用户明确表态(评分、点赞、收藏);隐式反馈是行为痕迹(点击、播放时长、停留、加购)。⭐ 隐式反馈的量大几个数量级——愿意打分的人极少,但每个人都在产生点击和停留。
  2. 因为「没点击」有太多种解释:没看到、看到了没兴趣、正在忙、已经在别处买过了。它们混在一起,无法区分「不喜欢」和「没机会看到」。⭐ 实践中拿「曝光未点击」当精排的负样本,「全库随机采样」当召回的负样本——两者分布不同,用错会让离线指标好看但线上崩掉
  3. 通常稀疏到 99.9% 以上(即只有不到 0.1% 的格子有值)。⭐ 这正是不能把它当普通矩阵处理的原因,也是矩阵分解、向量检索这些技术存在的理由。
  4. 因为随机切分会造成时间穿越——训练集里混进了「未来」的交互,模型见过了本不该见的信息,离线分数虚高但线上完全不成立。⭐ 真实场景永远是「用过去预测未来」,验证方式必须和它一致。
  5. 长尾分布 = 少数物品占据绝大部分交互量,大量物品几乎无人问津。⭐ 价值在长尾:头部内容用户自己就能找到(搜索、榜单都行),推荐系统真正不可替代的,是把长尾里那个恰好适合你的东西挖出来

🛑 可以停在这里

走神救援

数据 = 用户-物品交互矩阵,99%+ 是空的。显式反馈(评分)少而准,隐式反馈(点击)多而糙且没有真负样本。物品服从长尾分布,推荐的价值在长尾。三大坑:数据泄漏(要按时间切分)、曝光偏差、位置偏差。隐式反馈没有真负样本,这是全章最关键的一条:「没点击」混杂了没看到、没兴趣、正在忙、已在别处买过 —— 四种完全不同的情况。所以负样本必须按阶段分别构造精排用曝光未点击,召回用全库随机采样;用错会让离线指标好看但线上崩掉。⭐ 另外两个坑:按时间切分(随机切会时间穿越)和位置偏差(排第一的天然点击率高,不修正的话模型会学成「谁排前面推谁」)。

下一节 👉 04-协同过滤-最简单的算法.md

打卡记录保存在你的浏览器里,首页能看到总进度