简介:这是一套面向Python初学者与推荐系统爱好者的服饰推荐系统实战项目,围绕个性化服装推荐场景,综合运用数据采集、机器学习与Web开发技术,帮助读者理解从用户行为数据到精准推荐列表的完整链路。资源包共2000个文件,以9698张jpg服饰图片和22个csv数据集为主,辅以30个py脚本、27个js与8个vue前端文件、26个xml配置及bson、json等数据文件,压缩包约225.31MB,目录结构清晰,便于按模块检索。项目覆盖数据收集与预处理、特征工程、用户画像构建、协同过滤与矩阵分解等推荐算法、模型训练评估以及Flask/Django接口部署等关键环节,并配有scrapy爬虫配置与前端页面资源。已有318人学习下载,适合希望系统掌握推荐系统构建流程、积累数据驱动项目经验的开发者参考实践。
1. 从零搭一套 Python 服饰推荐系统:为什么它比你想的更好落地
电商后台里堆着几十万条订单,运营却还在用「销量 Top 20」给所有用户推同一批衣服,这是很多中小团队的真实状态。Python 服饰推荐系统要解决的就是这件事:把「谁买过什么」变成「谁可能想买什么」,并且用一套能跑在单机或小集群上的代码实现。它适合有 Python 基础、懂一点 pandas 的开发者,也适合想从零入门推荐算法的同学——服饰这个品类天然带颜色、尺码、风格、季节属性,特征工程空间大,做出来的效果比通用商品推荐更容易被业务感知。下面这套方案我按「数据准备 → 召回 → 排序 → 服务化」的路径拆开讲,每一步都能单独跑通,也能拼成完整链路。
2. 数据与特征:服饰推荐系统的地基怎么打
2.1 三张核心表与字段设计
任何推荐系统的起点都是数据。服饰场景下,我一般会准备三张表:用户行为表、商品属性表、用户画像表。行为表记录user_id、item_id、behavior_type(浏览/加购/下单)、timestamp;商品表记录item_id、category、color、size、price、style;画像表记录user_id、age_group、gender、preferred_style。这三张表用 pandas 读进来后,第一件事是统一 ID 类型,避免后面 merge 时因为 int 和 str 对不上导致空结果。
import pandas as pd # 读取三张核心表,dtype 显式指定避免 ID 被推断成 float behavior = pd.read_csv("behavior.csv", dtype={"user_id": str, "item_id": str}) item = pd.read_csv("item.csv", dtype={"item_id": str}) user = pd.read_csv("user.csv", dtype={"user_id": str}) # 行为表去重:同一用户对同一商品同一秒的重复上报只保留一条 behavior = behavior.drop_duplicates(subset=["user_id", "item_id", "behavior_type", "timestamp"]) # 时间戳转 datetime,后续做时间衰减要用 behavior["timestamp"] = pd.to_datetime(behavior["timestamp"]) print(behavior.shape, item.shape, user.shape)这段代码的关键在dtype指定和去重。服饰类目下,用户反复浏览同一件衣服的概率很高,不去重会让热门商品权重虚高。timestamp转成 datetime 是为了后面算「最近 7 天行为」和「时间衰减系数」。参数上,drop_duplicates的 subset 必须包含behavior_type,否则用户先浏览后下单会被误删一条。
2.2 服饰特有的特征:颜色、尺码、季节
通用推荐系统往往只用 category 和 price,但服饰品类里颜色和尺码的区分度极高。一个用户如果连续三次浏览 M 码,你给他推 XL 码就是浪费曝光。我一般会把颜色和尺码做 one-hot,再和用户历史行为做交叉。季节特征用月份映射:3-5 春、6-8 夏、9-11 秋、12-2 冬。这个映射表直接写死在代码里,比让模型自己学更稳。
# 颜色和尺码 one-hot color_dummies = pd.get_dummies(item["color"], prefix="color") size_dummies = pd.get_dummies(item["size"], prefix="size") item_feat = pd.concat([item[["item_id", "category", "price"]], color_dummies, size_dummies], axis=1) # 季节映射 season_map = {12: "winter", 1: "winter", 2: "winter", 3: "spring", 4: "spring", 5: "spring", 6: "summer", 7: "summer", 8: "summer", 9: "autumn", 10: "autumn", 11: "autumn"} behavior["season"] = behavior["timestamp"].dt.month.map(season_map) # 用户偏好的尺码:取最近 30 天出现次数最多的尺码 recent = behavior[behavior["timestamp"] > behavior["timestamp"].max() - pd.Timedelta(days=30)] size_pref = recent.merge(item[["item_id", "size"]], on="item_id") \ .groupby("user_id")["size"].agg(lambda x: x.mode().iloc[0] if not x.mode().empty else "M") print(size_pref.head())get_dummies生成的列数取决于颜色和尺码的枚举值,服饰类目一般颜色 10-20 种、尺码 5-8 种,维度可控。size_pref用众数而不是均值,因为尺码是离散的,均值没有物理意义。如果某个用户最近 30 天没有行为,mode()会返回空,这里用if not x.mode().empty兜底成 M 码,避免后续报错。
2.3 行为权重与时间衰减
浏览、加购、下单三种行为的信号强度完全不同。我一般设浏览 1 分、加购 3 分、下单 5 分。再叠加时间衰减:越近的行为权重越高,用指数衰减exp(-λ * days),λ 取 0.1 时大约 7 天前的行为权重降到一半。这个参数没有绝对标准,服饰换季快,λ 可以取大一点,比如 0.15。
import numpy as np weight_map = {"view": 1, "cart": 3, "order": 5} behavior["base_weight"] = behavior["behavior_type"].map(weight_map) # 时间衰减:以数据集中最新时间为基准 max_ts = behavior["timestamp"].max() behavior["days_ago"] = (max_ts - behavior["timestamp"]).dt.total_seconds() / 86400 behavior["decay"] = np.exp(-0.15 * behavior["days_ago"]) behavior["final_weight"] = behavior["base_weight"] * behavior["decay"] print(behavior[["user_id", "item_id", "behavior_type", "final_weight"]].head())final_weight就是后续召回和排序的输入。注意days_ago用总秒数除以 86400,比直接用.dt.days精度高。如果数据集时间跨度超过一年,建议先截断到最近 180 天,否则早期行为衰减到接近零,白白增加计算量。
3. 召回层:用 ItemCF 和内容相似度双路兜底
3.1 ItemCF 召回:共现矩阵怎么算不爆内存
ItemCF 的核心是「买了 A 的人也买了 B」。服饰场景下,共现矩阵的稀疏度很高,直接算全量 item-item 相似度会爆内存。我的做法是先按用户分组,取每个用户的行为序列,再两两组合生成 item 对。为了控制规模,只保留共现次数大于 2 的对。
from itertools import combinations from collections import defaultdict # 按用户聚合商品列表 user_items = behavior.groupby("user_id")["item_id"].apply(list).to_dict() # 统计共现 cooccur = defaultdict(int) for items in user_items.values(): unique_items = list(set(items)) if len(unique_items) > 50: # 异常用户截断 unique_items = unique_items[:50] for a, b in combinations(sorted(unique_items), 2): cooccur[(a, b)] += 1 # 转成 DataFrame 并过滤低频 cooccur_df = pd.DataFrame( [(a, b, c) for (a, b), c in cooccur.items() if c >= 2], columns=["item_a", "item_b", "co_count"] ) print(cooccur_df.shape)combinations生成的是无序对,用sorted保证 (A,B) 和 (B,A) 只出现一次。len(unique_items) > 50的截断是防止个别爬虫账号或测试账号把矩阵撑爆。co_count >= 2过滤掉偶然共现,服饰类目下这个阈值可以调到 3,看数据量决定。
3.2 相似度计算与 TopN 截断
有了共现次数,还要除以两个商品的流行度做归一化,否则热门商品会和所有商品都「相似」。公式是co_count / sqrt(count_a * count_b)。算完相似度后,每个商品只保留 Top 20 相似商品,存成字典供线上查询。
# 商品流行度 item_count = behavior.groupby("item_id")["user_id"].nunique().to_dict() cooccur_df["sim"] = cooccur_df.apply( lambda r: r["co_count"] / np.sqrt(item_count.get(r["item_a"], 1) * item_count.get(r["item_b"], 1)), axis=1 ) # 每个 item 保留 Top20 相似 sim_dict = {} for item_a, group in cooccur_df.groupby("item_a"): top = group.nlargest(20, "sim")[["item_b", "sim"]].values.tolist() sim_dict[item_a] = top print(len(sim_dict), sim_dict.get(list(sim_dict.keys())[0]))item_count用nunique而不是count,因为同一用户多次浏览同一商品只算一次流行度。nlargest(20)的 20 是经验值,线上召回一般取 50-100 个候选,这里每个种子商品出 20 个,多个种子叠加后足够。sim_dict可以直接用 pickle 存下来,线上加载后查表。
3.3 内容相似度召回:解决冷启动
新品没有行为数据,ItemCF 召不回。这时候用内容相似度兜底:把商品的颜色、尺码、类目、价格分桶做向量,算余弦相似度。价格分桶我一般按分位数切 5 段,避免高价商品和低价商品被算成相似。
from sklearn.preprocessing import OneHotEncoder from sklearn.metrics.pairwise import cosine_similarity # 价格分桶 item["price_bin"] = pd.qcut(item["price"], q=5, labels=False, duplicates="drop") # 组合特征 item["content_feat"] = item["category"].astype(str) + "_" + \ item["color"].astype(str) + "_" + \ item["size"].astype(str) + "_" + \ item["price_bin"].astype(str) enc = OneHotEncoder() content_matrix = enc.fit_transform(item[["content_feat"]]) content_sim = cosine_similarity(content_matrix) # 转成 Top20 字典 content_sim_dict = {} for i, item_id in enumerate(item["item_id"]): sim_scores = list(enumerate(content_sim[i])) sim_scores = sorted(sim_scores, key=lambda x: x[1], reverse=True)[1:21] content_sim_dict[item_id] = [(item["item_id"].iloc[j], s) for j, s in sim_scores] print(len(content_sim_dict))qcut的duplicates="drop"是防止价格分布太集中导致分桶失败。content_feat用字符串拼接再 one-hot,比分开 one-hot 再拼接更省内存。cosine_similarity在商品量小于 10 万时可以直接算,超过 10 万建议用稀疏矩阵或近似最近邻库。
4. 排序层:用 LightGBM 把召回结果排好序
4.1 样本构造与负采样
召回层给出几百个候选,排序层要从中选出 Top 10 展示。训练样本用「曝光未点击」做负样本,「点击/加购/下单」做正样本。服饰场景下正负样本比例可能到 1:50,需要负采样到 1:5 左右。采样时按时间倒序取最近的负样本,避免用太久远的数据。
# 假设有曝光日志 exposure exposure = pd.read_csv("exposure.csv", dtype={"user_id": str, "item_id": str}) exposure["label"] = exposure["clicked"].astype(int) # 负采样 pos = exposure[exposure["label"] == 1] neg = exposure[exposure["label"] == 0].sample(n=len(pos) * 5, random_state=42) train = pd.concat([pos, neg]).sample(frac=1, random_state=42).reset_index(drop=True) print(train["label"].value_counts())sample(n=len(pos)*5)控制负样本数量,random_state固定保证可复现。如果曝光日志里没有clicked字段,可以用行为表反推:有行为的算正样本,没行为的算负样本,但要注意曝光未点击和未曝光的区别。
4.2 特征拼接与 LightGBM 训练
排序特征分三类:用户特征(年龄、性别、偏好尺码)、商品特征(类目、颜色、价格)、交叉特征(用户偏好颜色是否匹配商品颜色)。LightGBM 对类别特征支持好,直接指定categorical_feature即可。
import lightgbm as lgb from sklearn.model_selection import train_test_split # 拼接特征 train = train.merge(user, on="user_id", how="left") \ .merge(item_feat, on="item_id", how="left") # 交叉特征:用户偏好尺码是否匹配 train = train.merge(size_pref.rename("pref_size"), on="user_id", how="left") train["size_match"] = (train["pref_size"] == train["size"]).astype(int) feature_cols = ["age_group", "gender", "category", "color", "size", "price", "size_match"] X = train[feature_cols] y = train["label"] X_train, X_val, y_train, y_val = train_test_split(X, y, test_size=0.2, random_state=42) cat_features = ["age_group", "gender", "category", "color", "size"] dtrain = lgb.Dataset(X_train, y_train, categorical_feature=cat_features) dval = lgb.Dataset(X_val, y_val, categorical_feature=cat_features) params = { "objective": "binary", "metric": "auc", "learning_rate": 0.05, "num_leaves": 31, "min_data_in_leaf": 50, "feature_fraction": 0.8, "bagging_fraction": 0.8, "bagging_freq": 5, "verbose": -1 } model = lgb.train(params, dtrain, num_boost_round=500, valid_sets=[dval], callbacks=[lgb.early_stopping(50)]) print(model.best_iteration, model.best_score)num_leaves=31是 LightGBM 的常用起点,服饰数据特征不多,不需要太深。min_data_in_leaf=50防止过拟合,数据量小于 10 万时调到 100。early_stopping(50)表示验证集 AUC 50 轮不提升就停。categorical_feature里的字段必须是整数编码或字符串,LightGBM 会自动处理。
4.3 排序结果重排:打散与多样性
模型打分后直接取 Top 10 会有问题:同一类目可能占满整个列表。我一般做类目打散,同一类目最多出现 3 个,剩下的位置用次优商品补。这个逻辑在服务层做,不放进模型。
def rerank(scores, item_info, top_k=10, max_per_category=3): scores = sorted(scores, key=lambda x: x[1], reverse=True) result = [] category_count = defaultdict(int) for item_id, score in scores: cat = item_info[item_id]["category"] if category_count[cat] >= max_per_category: continue result.append((item_id, score)) category_count[cat] += 1 if len(result) >= top_k: break return result # 示例 item_info = item.set_index("item_id")[["category"]].to_dict("index") scores = [(iid, 0.9 - i * 0.01) for i, iid in enumerate(item["item_id"].head(50))] print(rerank(scores, item_info))max_per_category=3是经验值,服饰类目下可以调到 2 让列表更多样。rerank的时间复杂度是 O(n log n),线上候选几百个时耗时可忽略。如果业务要求强多样性,可以再加颜色打散。
5. 避坑与排查:服饰推荐系统最常见的 5 个翻车现场
5.1 现象:离线 AUC 0.85,线上点击率没涨
原因:离线样本用了曝光日志,但线上召回层没覆盖到那些商品。离线评估的是排序层在「给定候选集」上的表现,候选集本身有问题,排序再好也没用。解决:先看召回覆盖率,统计线上曝光商品中有多少来自召回层。如果覆盖率低于 80%,优先优化召回,别急着调排序模型。
5.2 现象:新品永远推不出去
原因:ItemCF 召回依赖行为共现,新品没有行为数据,召不回;排序模型训练样本里新品也少,打分偏低。解决:内容相似度召回给新品兜底,同时在排序层加一个「新品加权」特征,比如上架 7 天内的商品加 0.1 分。这个权重别太大,否则会伤害整体效果。
5.3 现象:用户反馈「推的尺码不对」
原因:size_pref用最近 30 天众数,但用户可能最近在给家人买衣服,尺码偏好变了。解决:把size_pref拆成「自用」和「送礼」两个场景,用收货地址或订单备注区分。如果区分不了,至少把最近 7 天的尺码偏好单独做一个特征,让模型自己学权重。
5.4 现象:训练时 AUC 很高,上线后打分分布和离线不一致
原因:离线特征用 pandas 算,线上用 SQL 或 Redis 算,两边逻辑不一致。比如离线days_ago用数据集最新时间,线上用当前时间,导致衰减系数不同。解决:把特征计算逻辑封装成统一函数,离线和线上调同一个函数。如果做不到,至少写单元测试对比两边输出。
5.5 现象:服务响应时间超过 200ms
原因:召回层查sim_dict是内存操作很快,但排序层每次都要拼特征、调 LightGBM,如果特征从数据库逐条查就会慢。解决:用户特征和商品特征预加载到 Redis,排序时批量取。LightGBM 用predict的num_threads参数控制并发,线上一般设 4 线程,再高收益递减。
6. 进阶技巧:用 FAISS 把召回从 200ms 压到 20ms
当商品量超过 10 万,sim_dict查表虽然快,但内容相似度召回如果每次都要算余弦相似度就慢了。我一般用 FAISS 做向量检索,把商品内容向量预训练好存进索引,线上查询时直接搜 TopN。下面是一个最小示例。
import faiss import numpy as np # 假设 content_matrix 是稀疏矩阵,先转 dense vectors = content_matrix.toarray().astype("float32") faiss.normalize_L2(vectors) # 归一化后内积等于余弦相似度 # 建索引 dim = vectors.shape[1] index = faiss.IndexFlatIP(dim) # 内积索引 index.add(vectors) # 查询:给一个用户历史商品,取平均向量作为 query user_vec = vectors[0:1] # 示例 faiss.normalize_L2(user_vec) D, I = index.search(user_vec, 20) print(I, D)IndexFlatIP是精确检索,商品量小于 100 万时够用。超过 100 万换IndexIVFFlat,但需要训练。normalize_L2必须在add和search前都做,否则内积不等于余弦。I返回的是向量下标,需要提前存好item_id到下标的映射。
| 方案 | 召回耗时 | 召回率 | 适用规模 |
|---|---|---|---|
| 字典查表 | 5ms | 高 | < 10 万 |
| FAISS 精确 | 20ms | 高 | < 100 万 |
| FAISS 近似 | 10ms | 中 | > 100 万 |
验证方法:用离线测试集算召回率,对比 FAISS 和字典查表的结果重合度。如果重合度低于 95%,检查归一化和索引参数。我自己的习惯是每次上线新召回策略前,先跑一遍 A/B 测试,观察点击率和转化率,别只看离线指标。希望帮到你。
本文还有配套的精品资源,点击获取