📑 本页目录(点开跳转)
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 | 竞赛视角的验证集构造 |
✅ 检查点
- 显式反馈和隐式反馈的区别?哪个量更大?
- 隐式反馈为什么没有真正的负样本?实践中拿什么当负样本?
- 用户-物品矩阵大概有多稀疏?
- 为什么切分数据集要按时间而不能随机?
- 长尾分布是什么?推荐系统的价值在头部还是长尾?
👀 答案
- 显式反馈是用户明确表态(评分、点赞、收藏);隐式反馈是行为痕迹(点击、播放时长、停留、加购)。⭐ 隐式反馈的量大几个数量级——愿意打分的人极少,但每个人都在产生点击和停留。
- 因为「没点击」有太多种解释:没看到、看到了没兴趣、正在忙、已经在别处买过了。它们混在一起,无法区分「不喜欢」和「没机会看到」。⭐ 实践中拿「曝光未点击」当精排的负样本,「全库随机采样」当召回的负样本——两者分布不同,用错会让离线指标好看但线上崩掉。
- 通常稀疏到 99.9% 以上(即只有不到 0.1% 的格子有值)。⭐ 这正是不能把它当普通矩阵处理的原因,也是矩阵分解、向量检索这些技术存在的理由。
- 因为随机切分会造成时间穿越——训练集里混进了「未来」的交互,模型见过了本不该见的信息,离线分数虚高但线上完全不成立。⭐ 真实场景永远是「用过去预测未来」,验证方式必须和它一致。
- 长尾分布 = 少数物品占据绝大部分交互量,大量物品几乎无人问津。⭐ 价值在长尾:头部内容用户自己就能找到(搜索、榜单都行),推荐系统真正不可替代的,是把长尾里那个恰好适合你的东西挖出来。
🛑 可以停在这里
⚡ 走神救援
数据 = 用户-物品交互矩阵,99%+ 是空的。显式反馈(评分)少而准,隐式反馈(点击)多而糙且没有真负样本。物品服从长尾分布,推荐的价值在长尾。三大坑:数据泄漏(要按时间切分)、曝光偏差、位置偏差。⭐ 隐式反馈没有真负样本,这是全章最关键的一条:「没点击」混杂了没看到、没兴趣、正在忙、已在别处买过 —— 四种完全不同的情况。所以负样本必须按阶段分别构造:精排用曝光未点击,召回用全库随机采样;用错会让离线指标好看但线上崩掉。⭐ 另外两个坑:按时间切分(随机切会时间穿越)和位置偏差(排第一的天然点击率高,不修正的话模型会学成「谁排前面推谁」)。
下一节 👉 04-协同过滤-最简单的算法.md