刷短视频越刷越窄?SVD 奇异值分解,把 10 万条电影评分拆成你的品味特征

刷短视频越刷越窄?SVD 奇异值分解,把 10 万条电影评分拆成你的品味特征

这事是怎么来的

昨晚在短视频里刷了个露营的视频,今天一打开,首页就排满了一连串露营装备、露营攻略。你以为平台真的「懂你」,其实背后是一张表,和一次矩阵分解。

举个例子:有个公开数据集叫 MovieLens 100K,943 个人给 1682 部电影打了 1 到 5 分的评分,加起来正好 10 万条。把这些分数摊平,就是一张「评分矩阵」。你没看过的电影,那一格就是空的。整张表里 93.7% 的格子是空的,数学上管这叫稀疏矩阵。

我要回答的大问题是:推荐系统到底凭什么猜中你想看什么?那个被大家反复讨论的「信息茧房」,又是怎么来的?这两件事,能不能用一次奇异值分解(SVD)一块说清楚。

Q1:把「用户 + 电影」变成数学对象

每行是一个用户,每列是一部电影,格子里是 1 到 5 分的评分,没看过的就是空。943 乘 1682,一共约 158 万个格子,其中只有 10 万个有数,剩下 93.7% 是空的。

这就是一个矩阵,而且是很稀疏的矩阵。稀疏不代表没结构。 看下面这张热图,红的一块、蓝的一块,并不是随机散布的,而是局部成团。 同一小群人,看过的电影高度重叠,就在图上留下一小片亮区。

所以第一件事很简单:世界上很多数据,天然就是矩阵。用户是一行,电影是一列,空格也是信息的一部分。

Q2:评分之间的相关性

电影之间是孤立的,还是有人有相同的喜好? 做法很朴素:把每一列减去它自己的平均分(这步叫中心化),再看哪两部电影总被同一拨人一起打高分。

在线性代数里,把所有电影两两这样比一遍,得到的是电影和电影之间的相关结构,中心化之后的 RTR RᵀR RTR 就是它的雏形。 爱看《教父》的人,大多也爱看《好家伙》,这两部在相关结构里靠得很近。 喜欢科幻的一群,和喜欢儿童动画的一群,明显就很少有交叉。

这一步的意义在于,它把「电影和电影之间什么关系」一次性摊开。而 SVD 能从中拆出「品味特征」,靠的正是这个相关性基础。

Q3:把人群里不同的品味,规范化为少数几个特征品味

现在上主角,奇异值分解。它把一个矩阵拆成三块:
R≈UΣVTR ≈ UΣVᵀ R≈UΣVT

U 是每个用户的「口味画像」, VTVᵀ VT 是每部电影的「风格画像」,中间 Σ 是一串从大到小排的奇异值。奇异值越大,对应的特征就越「主导」。

我跑了一次:前10来个特征,就已经能相当好地还原你没看过的电影评分。 换句话说,把原本 1682 维的「你看没看过什么」,压成了10来个品味特征,大意不丢。

听着还是有点绕,其实意思很直白,我用几个你天天见的场面对照一下。

它像 MBTI 那套人格分类。MBTI 用几个维度,把人分进十六型。SVD 干的是同类的事,只是它用的特征不是四个而是几十个,而且你不用做测试题,模型从你刷过的片子、点过的赞里自己把你量出来。

你刷得越久,系统对你的「隐藏分」估得越准,推给你的东西就越对味,也越窄。

就像像网易云每年甩给你的「年度听歌报告」,也就是你的「听歌 DNA」。报告说你是怀旧派还是新潮党,本质就是把你的听歌习惯压成一个人格画像。

最直观的证据在下面这张图。把每部电影按前两把品味特征投到平面上,按类型上色,你会看到喜剧抱成一团、科幻抱成一团、儿童动画又是另一团。图里还标了《玩具总动员》《星球大战》等八部片子,各自落进对应的团。线性代数不是在空中盖楼,它真的在这堆评分里,找到了「类型」这个结构。

Q4:这套压缩损失了多少,又省了多少维度;信息茧房又怎么来

拆完了,得回答三笔账。

第一笔,是能量账。奇异值衰减得很快,前 50 个特征(只占全部 5%)就装下了 39% 的能量。因为原始评分又稀疏又有噪声,并不是「少数特征能表达绝大部分」理想状态。恰恰说明「稀疏矩阵的降维」和「低噪连续曲线的拟合」是两码事。

第二笔,是预测账。我留了 10% 的评分当考题,用不同数量的口味尺子去重建、去预测。 k 太小,学不到东西;k 太大,反而包含了噪声。 结果是一条 U 形曲线:k=10 时留出误差最小,约 0.947 分,到 k=50 反而回升到 0.963。 多不是好,是过拟合,这是真实的风险。

第三笔,是茧房账。我挑了一个中等活跃的用户(已经看过 43 部),让算法每轮给他推 20 部「最懂他」的电影,然后看他推荐列表里的类型多样性。 用熵来量:随机挑 20 部,多样性熵是 3.12 比特,这是满的;算法推了 25 轮之后,最后 10 轮平均只剩 2.47 比特,比随机基线收窄了 20.7%。

上图把这一段说的事画了出来:橙线每轮往下掉,蓝虚线是「随机挑 20 部」的满多样性基线,橙点线是最后 10 轮的平均。我把这段单独写成了一份可复现脚本 cocoon_simulation.py,跑一遍就能复现上图(3.12 → 2.47,收窄 20.7%)。

python 复制代码
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
AI观星记 · 线性代数篇(H4)· 信息茧房账 · 独立可复现脚本
==================================================================
对应正文章节「Q4 第三笔:茧房账」。
把"推荐系统如何把人越推越窄"这一个实验,单独拎出来写成一份
干净可跑、可出图的代码------不依赖其他图,只复现这一笔的数字。

实验设定(与正文逐字对齐):
  - 数据集:MovieLens 100K(943 用户 × 1682 电影,10 万条评分)
  - 选一个中等活跃用户 user#615,已看过正好 43 部电影
  - 每轮用 SVD 口味尺子挑出 20 部"最懂他"的电影作推荐列表
  - 量这个列表的"类型多样性熵"(越单一,熵越低)
  - 让"用户看下最贴近的一部"→ 口味画像被往已知方向推 → 下一轮重算
  - 跑 25 轮,看熵怎么坍缩

可复现数字(用本机 data/ml-100k,random seed=42):
  随机挑 20 部基线熵 = 3.12 bit(满多样性)
  用户 #615 初始(第 1 轮)熵 ≈ 2.65 bit
  25 轮后,最后 10 轮平均熵 = 2.47 bit
  相对随机基线收窄 = 20.7%
"""
import os
import numpy as np
import pandas as pd
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib import font_manager

# ---------- 字体(中文用 STHeiti,缺失则回退) ----------
CN_FONT = "/System/Library/Fonts/STHeiti Light.ttc"
if os.path.exists(CN_FONT):
    font_manager.fontManager.addfont(CN_FONT)
    cn_name = font_manager.FontProperties(fname=CN_FONT).get_name()
    plt.rcParams["font.family"] = cn_name
else:
    print("未找到 STHeiti,回退 DejaVu Sans(中文可能缺字)")
plt.rcParams["axes.unicode_minus"] = False
plt.rcParams["figure.dpi"] = 150
plt.rcParams["savefig.dpi"] = 300

BASE_DIR = os.path.dirname(os.path.abspath(__file__))

DATA = BASE_DIR + "/ml-100k"
OUT = BASE_DIR + "/assets"
os.makedirs(OUT, exist_ok=True)

N_ROUNDS = 25          # 推荐轮数
TOPN = 20              # 每轮推荐列表长度
USER_ID = 616          # 中等活跃用户(矩阵 row index 615):恰好看过 43 部

# ---------- 1. 读数据 ----------
ratings = pd.read_csv(f"{DATA}/u.data", sep="\t",
                      names=["user", "item", "rating", "ts"])
items = pd.read_csv(f"{DATA}/u.item", sep="|", encoding="latin-1",
                    usecols=range(24),
                    names=["movie_id", "title", "release", "video", "url"] +
                          ["g%d" % i for i in range(19)])
GENRE_COLS = ["g%d" % i for i in range(19)]
genre_mat = items[GENRE_COLS].values.astype(float)   # n_items × 19(哑变量, 含一列全0)

n_users = ratings["user"].nunique()
n_items = ratings["item"].nunique()
print(f"原始规模:{n_users} 用户 × {n_items} 电影,{len(ratings)} 条评分")

u_idx = {u: i for i, u in enumerate(sorted(ratings["user"].unique()))}
i_idx = {m: j for j, m in enumerate(sorted(ratings["item"].unique()))}
ratings["ui"] = ratings["user"].map(u_idx)
ratings["mi"] = ratings["item"].map(i_idx)

# ---------- 2. 建评分矩阵并中心化(列均值) ----------
R = np.full((n_users, n_items), np.nan)
R[ratings["ui"].values, ratings["mi"].values] = ratings["rating"].values
col_mean = np.nanmean(R, axis=0)
Rc = R - col_mean

# ---------- 3. 留出验证:抽 10% 已评分作测试集,其余训练(与 recsys_svd_explore.py 同 seed) ----------
rng = np.random.default_rng(42)
obs = ratings[["ui", "mi", "rating"]].values
n_test = max(1, int(0.1 * len(obs)))
test_idx = rng.choice(len(obs), n_test, replace=False)
test_set = obs[test_idx]
train_mask = np.ones(len(obs), dtype=bool)
train_mask[test_idx] = False
train_set = obs[train_mask]

Rtr = np.full((n_users, n_items), 0.0)
Rtr[train_set[:, 0].astype(int), train_set[:, 1].astype(int)] = \
    train_set[:, 2] - col_mean[train_set[:, 1].astype(int)]

U, s, Vt = np.linalg.svd(Rtr, full_matrices=False)

# ---------- 4. 随机基线:随机抽 20 部电影,类型多样性熵的均值(满多样性) ----------
ent_baseline = []
for _ in range(1000):
    sample = rng.choice(n_items, TOPN, replace=False)
    p = genre_mat[sample].sum(axis=0)
    p = p / p.sum()
    ent_baseline.append(-np.sum(p[p > 0] * np.log2(p[p > 0])))
ent_random = float(np.mean(ent_baseline))

# ---------- 5. 茧房模拟:对 user#615 跑 25 轮 ----------
user = u_idx[USER_ID]
n_rated0 = int((ratings["ui"] == user).sum())
assert n_rated0 == 43, f"预期 user#{USER_ID} 看过 43 部,实际 {n_rated0}"

rated = set(ratings.loc[ratings["ui"] == user, "mi"].values)
user_vec = U[user] * s          # 口味画像 = U[:,user] ⊙ Σ
ent_hist = []
for rnd in range(N_ROUNDS):
    # 还没看过的候选电影
    cand_m = np.array([j for j in range(n_items) if j not in rated])
    # 用口味尺子量"最懂他":候选电影潜向量 与 用户画像 的距离
    d = np.linalg.norm((Vt[:, cand_m] * s[:, None]) - user_vec[:, None], axis=0)
    top20 = cand_m[np.argsort(d)[:TOPN]]
    # 这个推荐列表里,类型分布有多多样 → 熵
    p = genre_mat[top20].sum(axis=0)
    p = p / p.sum()
    ent = -np.sum(p[p > 0] * np.log2(p[p > 0]))
    ent_hist.append(ent)
    # 用户"看了最贴近的一部" → 画像被往已知方向推 → 下一轮重算
    nxt = cand_m[np.argmin(d)]
    rated.add(nxt)
    user_vec = user_vec + Vt[:, nxt] * s

ent_init = ent_hist[0]
ent_last10 = float(np.mean(ent_hist[-10:]))
narrow = (1 - ent_last10 / ent_random) * 100

print(f"\n=== 信息茧房账(user#{USER_ID},初始已看 {n_rated0} 部)===")
print(f"  随机 20 部基线熵     = {ent_random:.2f} bit(满多样性)")
print(f"  第 1 轮推荐熵        = {ent_init:.2f} bit")
print(f"  末 10 轮平均熵       = {ent_last10:.2f} bit")
print(f"  相对基线收窄         = {narrow:.1f}%")

# ---------- 6. fig4:信息茧房熵随推荐轮次坍缩(单面板,Okabe-Ito 配色,去脊线) ----------
BLUE = "#0072B2"     # 基线
ORANGE = "#D55E00"   # 熵曲线
GREY = "#999999"

fig, ax = plt.subplots(figsize=(8, 5))
rounds = list(range(1, N_ROUNDS + 1))

# 熵曲线
ax.plot(rounds, ent_hist, "o-", color=ORANGE, lw=2, ms=5,
        label="每轮 Top-20 推荐列表的类型多样性熵")

# 随机基线(满多样性)
ax.axhline(ent_random, ls="--", color=BLUE, lw=1.6,
           label=f"随机挑 20 部基线 = {ent_random:.2f} bit")

# 末 10 轮平均线
ax.axhline(ent_last10, ls=":", color=ORANGE, lw=1.4, alpha=0.8,
           label=f"末 10 轮均值 = {ent_last10:.2f} bit")

# 标注末 10 轮区间
ax.axvspan(N_ROUNDS - 9.5, N_ROUNDS + 0.5, color=ORANGE, alpha=0.06)
ax.annotate(f"末 10 轮均值 {ent_last10:.2f} bit\n比随机基线收窄 {narrow:.1f}%",
            xy=(N_ROUNDS, ent_last10), xytext=(N_ROUNDS - 11, ent_last10 + 0.28),
            fontsize=10, color=ORANGE,
            arrowprops=dict(arrowstyle="->", color=ORANGE, lw=1.2))

ax.set_xlabel("推荐轮次(每轮让算法推 20 部「最懂他」的电影)")
ax.set_ylabel("推荐列表的类型多样性熵(bit)")
ax.set_title("信息茧房的数学签名:推荐越准,列表越窄")
ax.set_ylim(ent_last10 - 0.35, ent_random + 0.35)
ax.set_xlim(0.5, N_ROUNDS + 0.5)
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
ax.legend(loc="lower left", fontsize=9, framealpha=0.9)
fig.tight_layout()
os.makedirs(OUT, exist_ok=True)
fig.savefig(f"{OUT}/fig4_cocoon_entropy.png")
plt.close(fig)
print(f"\nfig4 已写入 {OUT}/fig4_cocoon_entropy.png")

故事到这里就收齐了。SVD 让「懂你」成立:只用 10 把品味特征,就能猜中你还没看过的评分。代价是,它越懂你,就越把你往你已经喜欢的方向推,推荐列表的类型种类越来越少。

总结一下:

  1. 数据天然是矩阵:每一行一个样本(用户)、每一列一个特征(电影),稀疏的空格也是结构的一部分。
  2. SVD 是在找主方向:奇异值从大到小,前几个就装下大部分主口味;用 k 把口味尺子重建评分,k 太小不行,k 太大过拟合,k 约等于 10 是 MovieLens 100K 的甜点。
  3. 信息茧房有数学签名:推荐系统的 Top-N 列表,类型多样性熵会稳定低于随机基线。你越看,算法越往已知方向推,观看到的视频特征就被一点点压窄。

怎么验证(🏃 可复现)

数据用公开的 MovieLens 100K,下载地址:files.grouplens.org/datasets/mo...

输出会打印:矩阵规模与稀疏度、奇异值累计方差、留出 RMSE 随 k 的变化、信息茧房熵。诚实说一句局限:MovieLens 是 1998 年前后的数据,结论的「方向」对今天的推荐系统同样成立,思想同源(Netflix Prize 就是同一套矩阵分解)。

最后想问你

你最近刷哪个 App 时,感觉被困在同类内容里了?是短视频、购物,还是音乐?评论区聊聊

相关推荐
大模型码小白19 分钟前
Spring AI 框架中集成 MCP 的完整指南:从服务端到客户端的全流程实践
大数据·运维·数据库·人工智能·python·sql·spring
青 春 记 忆23 分钟前
零基础入门python29:用 pytest 验收完整 Flask 业务流程
python·flask·后端开发
wanglei20070833 分钟前
消息队列的协作模式
开发语言·python
三十岁老牛再出发43 分钟前
8月25-26日总结
python·pandas
小江的记录本1 小时前
【ORM 框架】MyBatis-Plus 核心特性、条件构造器、分页插件、乐观锁插件(附《思维导图》、《问题排查与实践清单》和《面试高频考点汇总》)
java·windows·spring boot·python·spring·面试·mybatis
CODER03041 小时前
win11系统编译安装cuda版llama-cpp-python(踩完所有的坑)
开发语言·python·llama
严谨的麻辣烫1 小时前
批量静态 IP 如何管理?用 Python 建立一个简单的 IP 资源监控方案
运维·服务器·网络·python·tcp/ip
卷无止境1 小时前
FastAPI生产环境密钥管理全解析,从一个.env文件说起
后端·python·fastapi
卷无止境1 小时前
SigV4与HTTPS,两套完全不同维度的安全机制
后端·python·fastapi