第10章 推荐系统:从候选集合到个性化排序

本章技术主题: 基于用户—物品交互的召回与个性化排序。
本章技术实验: 从离线可审核的学习与休闲资源目录中训练并评价一个小型推荐模型。
家庭项目: “完成一次有依据的消费或出行选择”是判断迁移任务,不冒充协同过滤推荐。
跨章位置: 本章形成候选、硬约束、来源时间和退出方案,供自主生活项目复用。
技术主线: 资源与交互 → 硬约束 → 用户/物品表示 → 候选召回 → 排序 → 人工选择。

学习目标

完成本章学习后,你将能够:

  1. 建立来源、授权和字段可核对的学习与休闲资源候选集,并把硬约束与个性化偏好分开。
  2. 说明交互数据、用户表示、物品表示、候选召回和排序之间的关系。
  3. 运行一个小型Keras矩阵分解程序,并与人工硬约束和热门排序基线比较。
  4. 通过冷启动、反馈偏差、隐私和多样性测试,判断推荐结果的适用边界。

项目导入

本章的技术主题是推荐系统。实践任务是从一份离线保存、经过审核的学习与休闲资源目录中,为不同学习者推荐下一项资源。资源可以包括短视频、图文教程、交互练习、项目指南、自然观察短片和博物馆虚拟展导览。目录保留来源页面、授权范围、核对时间和版本,系统输出若干候选及排序理由,学生再根据课程目标、兴趣和实际时间确认。

推荐不是给每个资源人工写一组权重后计算总分。真正的推荐机制会记录用户与物品之间的交互,从许多用户的选择中学习用户和物品的表示,先召回较小的候选集合,再对候选进行个性化排序。人工规则仍然重要,但它承担的是授权、时长、语言和先修条件等硬约束,不冒充推荐模型。

本章的“应用中的AI”是由交互数据训练的推荐模型,它预测某位用户可能对某项资源的偏好。“开发与学习AI”协助学生设计字段、解释矩阵分解代码、检查数据泄漏和归纳失败样例。学生必须决定哪些行为可以记录、哪些约束不可突破,并审查推荐是否把短期点击误当成长期学习目标。

课堂只使用虚构用户编号和模拟交互,不记录真实姓名、账号、成绩或浏览历史。推荐结果用于理解技术机制,不用于给学生贴能力标签,也不自动决定课程、专业或升学机会。

本章成果

考点提示

请重点区分:硬约束过滤不是个性化推荐;热门榜单是非个性化基线;召回追求从大目录中找出可能相关的一小批物品;排序负责精细比较这批候选;没有被点击不一定表示不喜欢,因为用户可能从未看到它。

第一节 建立可核对的学习与休闲资源候选集

一、推荐从物品目录开始

推荐系统不能从空白中“想象”资源。每项资源先进入候选目录,并拥有稳定编号和可核对字段。课堂使用本地保存的目录快照和来源记录,不依赖随时变化的在线接口;核对人员应能根据来源编号打开保存的页面说明或元数据,知道“当时为什么允许它进入目录”。

字段 示例 作用
资源编号 R006 跨表关联,不使用标题猜测身份
资源类型 学习、休闲 支持不同使用目的和评价标准
主题 时间序列入门 说明主要内容
难度 1—3级 与学习准备程度匹配
形式 图文、视频、练习 表达学习方式
预计时长 35分钟 检查可用时间
来源与授权 教师核验、课堂可用 决定能否进入候选集
核对时间与版本 2026-05-18、v1 说明目录快照何时有效
先修条件 表格数据基础 避免顺序不合理

标题、标签和难度都可能由人工填写错误。入库时应核对来源页面、授权范围和更新时间,随机抽查内容是否与标签相符。资源下线后要保留编号和下线原因,不能悄悄把同一编号换成另一内容,否则历史交互会指向错误物品。

二、硬约束先于推荐分数

假设某学习者只有45分钟、需要中文材料,并且只能使用课堂授权资源。系统应先删除超时、语言不符、未授权和先修条件不满足的资源,再进行推荐。即使模型给未授权资源很高分,也不能让分数突破硬约束。

硬约束是“必须满足”的条件,偏好是“满足约束后更喜欢什么”。把两者混成加权总分会产生危险结果:未授权资源可能凭借高热门度抵消授权扣分,超时资源可能凭借主题相似获得高总分。合理流程是先过滤,再召回和排序。

过滤后先建立热门排序基线:按符合约束的资源在训练期内的完成次数或平均评分排列。热门榜单没有为个人学习,但它简单、稳定、容易解释。个性化模型如果长期不能超过这个基线,就没有证据说明收集更多个人数据值得。

三、交互不是内心偏好的直接读数

用户打开、收藏、完成、评分和跳过资源,形成用户—物品交互。显式评分直接表达1至5分偏好;隐式行为需要人为定义强度,例如完成比打开更强,但仍不能断言完成就代表喜欢。未点击还可能因为资源没有被展示,不能一律当作负反馈。

交互表至少包含匿名用户编号、资源编号、行为、时间和是否实际曝光。训练和评价必须按时间切分,避免用未来行为预测过去。若同一用户短时间重复打开同一资源,应先判断是持续学习、误触还是页面刷新,不能机械地当成多次独立喜欢。

把隐式行为换算成训练信号时,要保存公开的转换规则。例如“完整学习且通过练习”可以作为较强正反馈,“打开后立即退出”只能作为较弱信号,“已经展示但没有打开”仍不能直接等同于负评分。规则改变后应重新训练并用同一留出数据比较。若系统只保存换算后的一个数字,日后就无法判断偏差来自原始行为还是转换方法。

还要区分偏好与学习需要。用户可能更常点击轻松、熟悉的内容,但课程目标要求补足尚未掌握的基础。推荐系统可以在合格候选中预测偏好,却不应自行修改培养目标;必要先修和教师规定内容应作为清楚可见的约束或独立学习计划,而不是伪装成模型“最懂学生”的结果。

开发与学习AI可以帮助列出字段冲突和可能泄漏,但不应接收真实学生明细。学生使用模拟数据提问,并要求它说明每个推断依赖哪些字段。应用中的AI只使用经过许可的交互;学习目标、健康信息和家庭情况不得因为“可能提高准确率”就自动纳入。

第二节 从用户—物品表示到召回与排序

一、用向量表示潜在偏好

矩阵分解把交互数据想象成一张用户—物品矩阵。矩阵中已知位置是评分或行为强度,大量位置为空。模型为每位用户学习一个短向量,也为每项资源学习一个同长度向量。两个向量的点积较大,表示模型预测这位用户和这项资源较匹配。

向量各维通常没有预先指定的名称。某一维可能混合难度、形式和主题,也可能只是数据中的统计模式。用户向量不是对人的完整描述,物品向量也不是内容真理。它们只是在当前交互和损失下,为预测已观察行为形成的参数。

先用二维教学向量手算一次。若某用户向量为[0.8, 0.2],资源A为[0.7, 0.3],资源B为[0.1, 0.9],则点积分别为0.62和0.26。模型会把A看作更匹配,但这两个维度是训练得到的参数,不能擅自命名为“能力”和“兴趣”。完整程序做的仍是这类乘加,只是向量由训练过程学习。

候选召回先用较便宜的方法从大目录中找出可能相关的一小批物品。在本章小目录里可以计算全部合格物品与用户向量的相似度;目录很大时通常使用高效的近邻检索。排序阶段再使用带偏置的完整预测分数、上下文和可解释规则精细排列。召回漏掉的资源,排序阶段无法补回。

二、运行小型矩阵分解模型

下面程序使用5个虚构用户和8项学习、休闲资源的显式评分。资源来自固定的离线审核快照,代码中的CATALOG_SNAPSHOT_ID对应另存的来源与授权登记表,而不是实时抓取的网页目录。每条评分带有先后顺序;程序把每位用户最后一次交互留作测试,只用更早的交互训练。随后,它对用户U0过滤硬约束,先用向量点积召回候选,再用完整模型排序,并在所有用户的留出交互上比较个性化结果与热门基线。

import numpy as np
import tensorflow as tf
from tensorflow import keras

keras.utils.set_random_seed(7)

# 课堂程序使用固定的离线审核快照;来源页和授权记录保存在CAT-2026-05-18登记表。
CATALOG_SNAPSHOT_ID = "CAT-2026-05-18"
items = [
    {"id": 0, "title": "数据表入门", "minutes": 25, "authorized": True, "lang": "zh"},
    {"id": 1, "title": "绘制训练曲线", "minutes": 30, "authorized": True, "lang": "zh"},
    {"id": 2, "title": "图像分类练习", "minutes": 40, "authorized": True, "lang": "zh"},
    {"id": 3, "title": "声音分类练习", "minutes": 35, "authorized": True, "lang": "zh"},
    {"id": 4, "title": "高级视觉专题", "minutes": 45, "authorized": False, "lang": "zh"},
    {"id": 5, "title": "长时综合课程", "minutes": 90, "authorized": True, "lang": "zh"},
    {"id": 6, "title": "自然观察短片", "minutes": 45, "authorized": True, "lang": "zh"},
    {"id": 7, "title": "博物馆虚拟展导览", "minutes": 20, "authorized": True, "lang": "zh"},
]

# 四列依次是用户编号、资源编号、评分、该用户的交互顺序。
ratings = np.array([
    [0, 0, 5, 1], [0, 1, 4, 2], [0, 3, 5, 3],
    [1, 0, 4, 1], [1, 7, 4, 2], [1, 2, 5, 3],
    [2, 1, 5, 1], [2, 3, 5, 2], [2, 6, 4, 3],
    [3, 0, 3, 1], [3, 2, 4, 2], [3, 6, 5, 3], [3, 7, 4, 4],
    [4, 1, 4, 1], [4, 3, 5, 2], [4, 6, 4, 3],
], dtype="float32")

train_parts, test_rows = [], []
for user_id in np.unique(ratings[:, 0]).astype(int):
    user_rows = ratings[ratings[:, 0] == user_id]
    user_rows = user_rows[np.argsort(user_rows[:, 3])]
    train_parts.append(user_rows[:-1])
    test_rows.append(user_rows[-1])
train = np.vstack(train_parts)
test = np.array(test_rows, dtype="float32")
assert all(np.sum(train[:, 0] == user_id) >= 1 for user_id in range(5))
assert all(np.sum(test[:, 0] == user_id) == 1 for user_id in range(5))
assert np.all(test[:, 2] >= 4)


class MatrixFactorization(keras.Model):
    def __init__(self, user_count, item_count, dimensions=4):
        super().__init__()
        self.user_embedding = keras.layers.Embedding(user_count, dimensions)
        self.item_embedding = keras.layers.Embedding(item_count, dimensions)
        self.user_bias = keras.layers.Embedding(user_count, 1)
        self.item_bias = keras.layers.Embedding(item_count, 1)

    def call(self, inputs):
        users = tf.cast(inputs[:, 0], tf.int32)
        item_ids = tf.cast(inputs[:, 1], tf.int32)
        user_vectors = self.user_embedding(users)
        item_vectors = self.item_embedding(item_ids)
        dot_product = tf.reduce_sum(user_vectors * item_vectors, axis=1)
        bias = tf.squeeze(self.user_bias(users) + self.item_bias(item_ids), axis=1)
        return dot_product + bias


model = MatrixFactorization(user_count=5, item_count=len(items))
model.compile(optimizer=keras.optimizers.Adam(0.03), loss="mse")
model.fit(train[:, :2], train[:, 2], epochs=300, verbose=0)

global_mean = float(np.mean(train[:, 2]))
item_mean = {}
for item in items:
    known = train[train[:, 1] == item["id"], 2]
    item_mean[item["id"]] = float(np.mean(known)) if len(known) else global_mean


def eligible_items(user_id):
    seen = set(train[train[:, 0] == user_id, 1].astype(int))
    return [
        item["id"] for item in items
        if item["id"] not in seen
        and item["authorized"]
        and item["lang"] == "zh"
        and item["minutes"] <= 45
    ]


def recommend(user_id, k=3, recall_size=5):
    eligible = eligible_items(user_id)
    user_vector = model.user_embedding(np.array([user_id])).numpy()[0]
    item_vectors = model.item_embedding(np.array(eligible)).numpy()
    recall_scores = item_vectors @ user_vector
    recall_order = np.argsort(recall_scores)[::-1][:min(recall_size, len(eligible))]
    recalled = [eligible[index] for index in recall_order]

    ranking_input = np.array(
        [[user_id, item_id] for item_id in recalled], dtype="float32")
    ranking_scores = model.predict(ranking_input, verbose=0)
    ranked = sorted(
        zip(recalled, ranking_scores), key=lambda pair: float(pair[1]), reverse=True)
    personalized = [item_id for item_id, _ in ranked[:k]]
    popular = sorted(eligible, key=lambda item_id: item_mean[item_id], reverse=True)[:k]
    return recalled, personalized, popular


personalized_hits, popular_hits = 0, 0
for user_id, held_out_item, held_out_rating, _ in test:
    user_id, held_out_item = int(user_id), int(held_out_item)
    assert held_out_item not in set(train[train[:, 0] == user_id, 1].astype(int))
    _, personalized, popular = recommend(user_id, k=3)
    personal_hit = int(held_out_item in personalized)
    popular_hit = int(held_out_item in popular)
    personalized_hits += personal_hit
    popular_hits += popular_hit
    print({
        "user": user_id,
        "held_out": items[held_out_item]["title"],
        "rating": float(held_out_rating),
        "personal_top3": [items[item_id]["title"] for item_id in personalized],
        "popular_top3": [items[item_id]["title"] for item_id in popular],
        "personal_hit": personal_hit,
        "popular_hit": popular_hit,
    })

target_user = 0
recalled, personalized, popular = recommend(target_user, k=3)
target_held_out = int(test[test[:, 0] == target_user, 1][0])
print("U0留出资源:", items[target_held_out]["title"])
print("U0个性化召回:", [items[item_id]["title"] for item_id in recalled])
print("U0个性化Top-3:", [items[item_id]["title"] for item_id in personalized])
print("U0热门Top-3:", [items[item_id]["title"] for item_id in popular])
print("个性化命中率@3:", personalized_hits / len(test))
print("热门基线命中率@3:", popular_hits / len(test))

代码中的嵌入层保存用户和物品向量,均方误差推动预测评分接近训练期的已知评分。硬约束在召回前执行;未授权的R004和超过时长的R005即使模型分数高也不会进入候选。召回阶段只看用户与物品向量的点积,排序阶段再加入用户和物品偏置。热门基线只使用训练期各资源的平均评分,不使用目标用户向量,也不能偷看留出的最后一次交互。

程序实际完成了按时间留出和命中率@3比较,而不是只在文字中要求评价。若某位用户最后学习的资源进入Top-3,就记为一次命中;最终分数是五位模拟用户的命中比例。样本极小,具体比例没有推广价值。若个性化模型不优于热门基线,应保留简单方法,而不是用训练损失下降宣称推荐有效。配套Notebook还应显示交互矩阵、训练曲线、用户与物品向量及多次随机种子的差异。

三、离线评价不能只看训练损失

评价时可以把每位用户时间上较晚的一次交互留出,检查目标资源是否进入前K个推荐,形成命中率;还可统计目录中有多少资源得到曝光、不同主题是否保持一定多样性,以及热门和冷门资源的覆盖。训练损失低,只表示模型适合已知评分,不保证推荐有用。

还应设计在线前的人工审查。随机抽取推荐列表,检查硬约束、来源、先修顺序和重复内容。若个性化模型与热门基线差异很小,应优先保留简单基线;若模型只推荐同一主题,则要在排序后增加可说明的多样性规则,但必须把该规则与模型预测分开记录。

第三节 测试冷启动、反馈偏差与推荐边界

一、没有交互时模型认识不了新对象

新用户没有历史交互,矩阵分解无法学习可靠用户向量;新资源没有评分,也没有可靠物品向量,这称为冷启动。合理做法不是伪造个性化,而是明确进入冷启动路径:先应用硬约束,再展示经审核的热门、多样化资源,或请用户选择少量主题和形式偏好。

新资源还可以依据主题、难度和形式等内容特征进入召回,等积累真实交互后再与协同表示结合。冷启动问卷应少而透明,允许跳过和修改。系统不能把一次选择永久固定为用户画像。

二、推荐会改变未来数据

被排在前面的资源更容易获得点击,新的点击又会让它更热门,形成反馈回路。未曝光资源没有点击,不等于用户不喜欢。若训练数据只记录点击而不记录曝光位置,模型会把界面造成的机会差异误当成偏好。

可以保留少量安全、受约束的探索位置,让不同合格资源获得被看到的机会;同时记录资源是否展示、排在何处以及用户是否完成。探索不是随机推送不合格内容,硬约束始终有效。评价时按时间比较覆盖、多样性、完成和人工投诉,不能只追求点击量。

开发与学习AI可以依据匿名汇总表寻找“头部资源曝光越来越集中”等模式,但学生要检查统计口径和样本量。应用中的AI也不应自动根据敏感属性推断能力、经济情况或心理状态。删除交互记录、关闭个性化和查看主要推荐依据,都应成为可用功能。

三、形成推荐使用判断

测试至少包括:无历史的新用户;刚入库的新资源;只点某一主题的窄历史;热门资源被移除;授权字段变更;用户撤回历史数据;模型不可用。任何情况下,硬约束失败都应停止推荐,模型不可用则回退到经审核的非个性化基线。

学习资源推荐的目标不能被压缩成“让人停留更久”。合理指标应与任务一致,例如是否完成计划、是否覆盖必要先修、是否获得多样选择,以及用户能否理解并修改推荐。推荐只提供可选择的顺序,不决定学生能力,更不能隐藏其他合格资源。

判断卡应写明使用了哪些交互、保留多久、谁能查看、冷启动怎样处理、何时回退热门基线、怎样退出个性化。只有当个性化排序在独立数据和人工审查中稳定优于基线,并且隐私与维护负担可接受时,才有理由继续使用。

四、迁移任务:完成一次有依据的消费或出行选择

推荐技术还可以帮助我们看清“候选—约束—比较—选择”的一般结构,但不能把所有排序任务都称为推荐系统。学生任选一个低风险任务,例如比较三种通勤方案、一次周末公共文化场馆出行,或同类日用品的购买方案;只做资料整理和方案比较,不代替真实下单、预约或支付。

先建立本地options.csv快照。每个候选必须记录来源编号、来源页面、查询时间、适用日期、可取消条件和价格组成。教材不写死实时票价或商品价格;学生在实践当天查询,把原页面或页面存档与时间一起保存。来源不明、时间过期或条款看不清的候选进入待确认,不能让模型补写。

硬约束先于比较,例如总预算上限、出发与返回时段、必要的无障碍条件、授权或安全要求。通过过滤后再计算总成本:标价之外还要检查交通接驳、预约或服务费、配送、行李、设备租用、退改费用等隐藏费用,并注明哪些费用仍未知。输出至少保留一个合格备选、一个“维持原方案/暂不选择”的退出选项,以及条件变化后重新查询的时间点。

学生可以用透明加权表比较总成本、时间、便利性和可取消性,权重由使用者确认并允许调整。这是有来源的多条件决策支持,不是协同过滤推荐:它没有从许多用户—物品交互中学习用户向量和物品向量。开发与学习AI可以协助生成读取表格、检查缺失字段和计算总成本的代码,但不得虚构实时价格、隐藏费用或营业状态,最终方案仍由人核对来源后选择。

本章小结

推荐系统从可核对的离线物品目录和用户—物品交互开始。硬约束先删除不合格资源,热门排序提供非个性化基线。矩阵分解通过交互学习用户和物品向量;召回从大目录中寻找较小候选集,排序再精细比较。没有交互会产生冷启动,展示位置会形成反馈偏差。可靠推荐必须记录曝光、比较基线、评价覆盖与多样性、允许退出,并把最终选择留给人。消费或出行迁移任务同样要保留来源、查询时间、总成本、隐藏费用和退出方案,但透明加权比较不能冒充从交互中学习的协同过滤推荐。

关键术语

目标测试

一、单项选择题

  1. 下列哪一项最能体现真正的个性化推荐机制? A. 人工给资源加权求和 B. 根据用户—物品交互学习表示并排序 C. 只显示最新资源 D. 按标题字数排序
  2. 为什么未授权资源应在召回前被删除? A. 减少字体数量 B. 硬约束不能被高推荐分数抵消 C. 提高训练轮数 D. 让用户向量更长
  3. 召回阶段的主要作用是什么? A. 从大目录快速选出较小候选集 B. 最终确认学习效果 C. 修改资源授权 D. 生成用户姓名
  4. 新用户没有任何交互时,哪种处理更合理? A. 宣称已经完成个性化 B. 随机解除硬约束 C. 提供受约束的热门和多样化资源并允许选择偏好 D. 复制另一用户全部历史

二、判断并改错

  1. 判断并改错:“没有点击某资源,就说明用户不喜欢它。”
  2. 判断并改错:“加权打分表只要输出顺序,就可以称为由交互训练的推荐模型。”

三、简答与操作题

  1. 说明用户表示、物品表示和点积在矩阵分解中的关系。
  2. 画出或用文字描述“硬约束过滤—召回—排序—人工选择”的完整流程,并说明每步输入和输出。
  3. 为什么推荐系统要记录曝光位置?请举例说明不记录会造成的偏差。
  4. 某小组用透明加权表比较三种周末出行方案。请写出完成一次有依据的选择必须保留的来源与成本信息、硬约束、备选或退出方案,并说明为什么该表不能称为协同过滤推荐。