简介:面向人工智能与数据挖掘学习者的CSDN用户画像构建源码包,覆盖用户行为数据收集、预处理、特征工程、用户聚类、画像建模到推荐与营销应用场景的完整链路,适合希望从零掌握用户画像项目实战的初学者或相关从业者。压缩包共17个文件,以9个Python脚本为核心,涉及数据清洗、文本分词、词向量训练、模型训练与预测等关键环节,另含3个XML工程配置、2个pyc缓存、2个TXT说明及1个iml项目文件,整体仅17KB,轻量精悍且目录结构简明。已有111人学习下载,作为入门级实践参考具备较高性价比。源码按数据预处理、训练、工具模块等区域划分,可直接复用其中的路径配置与训练脚本,并借鉴用户分群与画像生成的实现思路,快速搭建自己的实验环境,进而迁移到推荐系统、广告投放等真实业务场景。
1. 为什么用户画像最适合做人工智能项目实践:完整闭环、可见可验
把人工智能项目实践的常见选题拉一份清单,用户画像大概是唯一不需要 GPU 也能把数据清洗、特征工程、模型训练、接口服务、效果评估全部串起来的项目。这也是 CSDN 上“人工智能项目实践用户画像源码”这类资源一直被下载的原因:它既能作为人工智能大作业、课程设计交差,也能直接改造成公司里的第一版用户画像系统。这篇笔记面向三类读者:要交人工智能大作业的学生、想从零搭画像系统的工程师、以及下载了一堆源码却不知道怎么改成自己数据的初学者。我按“数据准备 → 标签建模 → 服务落地 → 避坑 → 验证”的顺序展开,每一步都给出可复现的代码和参数说明。
2. 落地之前先把标签拆清楚:画像系统的数据结构与特征宽表
2.1 统计、规则、模型三层标签,缺哪层都会有后续麻烦
用户画像的落地物是标签,但标签和标签不一样。我见过的所有能跑起来的画像系统,内部都分三层:统计标签、规则标签、模型标签。很多从 CSDN 下载的源码只做了前两层,跑完看结果像是“用户画像”,实际离 AI 还差很远。
第一层是统计标签,直接对用户行为日志做聚合就能得到,比如“近 30 天登录天数”“累计收藏数”“最后一次活跃时间”。它没有模型参与,但它是整个画像系统的地基,也是后面模型特征的主要来源。统计标签的难点不在算法,而在口径:时间窗口多长、分母是什么、去重规则是什么,这些不定义清楚,后面所有模型标签都会被带偏。
第二层是规则标签,由业务方定义阈值,比如“连续 30 天未登录但此前高频活跃”判为流失预警,“近 90 天收藏超过 20 篇”判为深度用户。规则标签可解释性强、上线快,但硬伤也很明显:阈值是拍出来的,换一个业务场景就要重调,而且规则之间容易互相矛盾。
第三层是模型标签,靠机器学习从行为数据里推断,比如“感兴趣的技术领域是 Python 还是数据库”“付费意愿高低”“潜在流失风险”。模型标签的输出不是 0/1,而是概率值;它能在统计和规则标签覆盖不到的隐式偏好上给出判断,这才是“人工智能项目实践”这个标题里真正的 AI 含量。做模型标签的意义不是炫技,而是让画像系统具备泛化能力——当用户行为稀疏、规则无法覆盖时,模型还能给出一个带置信度的估计。
2.2 CSDN 场景下的最小数据集:日志字段、ID 打通与特征宽表
我先给出一套能直接复现的最小数据方案。假设你在做一个 CSDN 风格的内容平台用户画像,手上有一份用户行为日志,核心字段就四个:user_id、article_id、action、ts。action 限定为浏览、点赞、收藏、评论、分享五种,覆盖了内容平台最常见的用户表达。
| 字段 | 类型 | 说明 | 进入画像前要处理的事 |
|---|---|---|---|
| user_id | string | 用户主键 | 未登录访问要先归到临时访客 ID |
| article_id | int | 文章 ID | 关联文章维度表,拿技术分类 |
| action | string | 行为类型 | 集中在五个枚举值,越界值丢弃 |
| ts | datetime | 行为时间 | 检查时区是否统一、有没有未来时间 |
第一个坑就是 ID 打通。CSDN 这类平台同一个用户在 PC、移动端、未登录状态下可能对应多个 user_id、cookie 或设备指纹,要把它们映射成一个统一主键。常见做法是用户登录时用注册 ID 做一次回写映射,未登录行为先挂临时访客 ID。如果手里数据已经是一个 user_id 对应一个用户,可以跳过我说的这一步。
下面是模拟数据与清洗的完整代码。这是演示数据,但清洗逻辑和真实日志一致。
import pandas as pd import numpy as np np.random.seed(42) n = 8000 # 原始行为日志:用户ID、文章ID、行为类型、时间戳 log_df = pd.DataFrame({ 'user_id': np.random.randint(10001, 16001, n), 'article_id': np.random.randint(20001, 26001, n), 'action': np.random.choice( ['view', 'like', 'collect', 'comment', 'share'], n, p=[0.62, 0.14, 0.10, 0.09, 0.05] ), 'ts': pd.date_range('2024-01-01', '2024-03-31', periods=n) }) # 去抖:同一用户同一篇文章同一行为只保留一次 log_df = log_df.drop_duplicates(subset=['user_id', 'article_id', 'action']) log_df['ts'] = pd.to_datetime(log_df['ts']) log_df['dt'] = log_df['ts'].dt.date # 文章维度表:文章ID映射到技术分类 articles = pd.DataFrame({ 'article_id': np.arange(20001, 26001), 'category': np.random.choice( ['Python', 'Java', '大数据', '前端', '数据库', '人工智能'], 6000 ) }) log_with_cat = log_df.merge(articles, on='article_id', how='left')逻辑说明:drop_duplicates 是去重,不是去重计数。同一个用户短时间反复刷新同一篇文章、同一个行为,在画像语义里只算一次,这叫“行为去抖”。真实项目里这一步往往在数据采集端或数仓层做,画像脚本里再做一次是双保险。article_id 与文章维度表关联,是为后面建模做准备——只有行为日志没有文章维度,你无法知道用户对哪个技术领域感兴趣,兴趣标签就无从谈起。
接下来是特征宽表。这是全篇最重要的环节,用户画像的所有模型都吃这张表的特征。
# 固定统计窗口:2024-03-01 至 2024-03-31 cutoff = pd.Timestamp('2024-03-31') recent_start = cutoff - pd.Timedelta(days=30) recent_log = log_df[log_df['ts'] >= recent_start] # 按用户在窗口内聚合统计特征 user_features = recent_log.groupby('user_id').agg( view_cnt=('action', lambda x: (x == 'view').sum()), like_cnt=('action', lambda x: (x == 'like').sum()), collect_cnt=('action', lambda x: (x == 'collect').sum()), comment_cnt=('action', lambda x: (x == 'comment').sum()), share_cnt=('action', lambda x: (x == 'share').sum()), active_days=('dt', 'nunique'), last_active=('ts', 'max'), ).reset_index()这些特征含义很直白:view_cnt 是阅读量,collect_cnt 是收藏量,active_days 是活跃天数。统计标签生成的特征一般不做标准化就直接进决策树模型,后面如果换逻辑回归或神经网络,记得先做标准化。
再补一个特征:主题熵。它衡量一个用户阅读领域是集中还是分散。
# 计算每个用户的阅读主题熵 category_dist = ( log_with_cat[log_with_cat['ts'] >= recent_start] .groupby('user_id')['category'] .value_counts(normalize=True) .rename('proba') .reset_index() ) entropy_series = ( category_dist.groupby('user_id') .apply(lambda g: -(g['proba'] * np.log(g['proba'])).sum()) .rename('subject_entropy') ) user_features['subject_entropy'] = user_features['user_id'].map(entropy_series)逻辑说明:value_counts(normalize=True) 先算每个用户在 Python、Java 等分类上的阅读占比,再套信息熵公式。熵值小说明用户阅读方向非常专一,熵值大说明他什么都看。这个特征对区分“深度学习型用户”和“泛知识型用户”很有用,写入画像后直接影响推荐策略。
到这里,一张包含活跃度、互动强度、内容偏好多样性的用户特征宽表就建好了。它既是统计标签的成品,也是下一章模型标签的输入。这里有一句经验:统计窗口必须全篇统一。如果你前端写 30 天,后端调度写 7 天,最终画像标签会像开盲盒一样,同一批用户每次查询结果都不一样。
3. 从特征到标签:正负样本怎么定,LightGBM 参数怎么调
3.1 模型标签先定口径:什么叫“对 Python 感兴趣”
做模型标签前,最容易被忽略也最容易翻车的一步是定口径。口径定义决定了模型学什么。拿 CSDN 场景举例子,“对 Python 感兴趣”就不能拍脑袋用一个规则:近 30 天内浏览过 Python 文章的次数大于 5 就叫感兴趣。
我一般这么定义正样本:近 30 天内,对 Python 分类下的文章发生过收藏或评论的用户。收藏和评论是比浏览强得多的行为信号,用户愿意收藏说明内容对他有沉淀价值,愿意评论说明有表达欲。浏览行为太弱,爬虫也会产生大量浏览,不适合单独作为正样本口径。
负样本也不是“不对 Python 感兴趣”那么简单,要区分两种情况:
- 硬负样本:近 30 天有活跃行为,但从未浏览过 Python 类文章。这是训练负样本的主要来源。
- 灰色地带:浏览过一两次 Python 文章但从未收藏和评论。这类用户样本建议直接剔除,不属于二分类能稳定表达的对象。
3.2 构造训练集:负采样与按用户切分
有了口径,下一步是构造训练集。重点有二:负样本要采样,样本划分要按用户走。
# 用户 × 文章分类的行为聚合 user_cat_stats = ( log_with_cat[log_with_cat['ts'] >= recent_start] .groupby(['user_id', 'category']) .agg( view_cnt=('action', lambda x: (x == 'view').sum()), strong_cnt=('action', lambda x: ((x == 'collect') | (x == 'comment')).sum()) ) .reset_index() ) # 正样本:对 Python 有过强互动行为(收藏/评论) pos = user_cat_stats[ (user_cat_stats['category'] == 'Python') & (user_cat_stats['strong_cnt'] >= 2) ][['user_id']].copy() # 负样本池:近30天活跃但从未碰过 Python 类文章 has_python = user_cat_stats[user_cat_stats['category'] == 'Python']['user_id'].tolist() active_users = user_features['user_id'].tolist() neg_pool = list(set(active_users) - set(has_python)) num_neg = min(len(pos) * 3, len(neg_pool)) neg = pd.DataFrame({'user_id': np.random.choice(neg_pool, num_neg, replace=False)}) pos['label'] = 1 neg['label'] = 0 train_df = pd.concat([pos, neg], ignore_index=True) # 拼上用户行为特征 feature_cols = ['view_cnt', 'like_cnt', 'collect_cnt', 'comment_cnt', 'share_cnt', 'active_days', 'subject_entropy'] train_df = train_df.merge(user_features, on='user_id', how='left')逻辑说明:正负样本比例控制在 1:3 左右。正样本太少,负样本太多,模型学到的只是“大多数用户不感兴趣”这个先验分布,输出概率会整体偏低。负采样随机抽池子而不是全部做负样本,是为了控制训练集体积,同时避免对少数极端用户过拟合。
“按用户切分”是这里最关键的约束。很多初学源码用train_test_split直接切行,但 train_df 里每个用户只出现一次,按行切也只会切在用户级别。真正容易翻车的是某些源码把“用户行为记录”当训练样本,同一用户的多行行为同时落在训练集和测试集,这叫标签泄漏。我这里的做法是先按 user_id 切:
unique_users = train_df['user_id'].unique() np.random.shuffle(unique_users) train_users = unique_users[: int(len(unique_users) * 0.7)] test_users = unique_users[int(len(unique_users) * 0.7):] X_train = train_df[train_df['user_id'].isin(train_users)][feature_cols] y_train = train_df[train_df['user_id'].isin(train_users)]['label'] X_test = train_df[train_df['user_id'].isin(test_users)][feature_cols] y_test = train_df[train_df['user_id'].isin(test_users)]['label']在用户画像项目里,随机切行和按用户切行,离线指标可能差出十个点。前者分数虚高,上线后立刻缩水,这是最常见的翻车点之一。
3.3 训练模型与两类关键参数
选 LightGBM 而不是逻辑回归,不是因为深度学习不好,而是用户画像的特征以稀疏计数为主,逻辑回归需要大量人工特征组合,LightGBM 可以在叶子节点自动做组合。选它而不选 XGBoost 只是因为训练更快、内存占用更低。在 AI 大作业和时间有限的工程场景里,快就是优势。
import lightgbm as lgb from sklearn.metrics import classification_report clf = lgb.LGBMClassifier( n_estimators=200, learning_rate=0.05, num_leaves=15, max_depth=4, min_child_samples=20, feature_fraction=0.8, subsample=0.8, random_state=42 ) clf.fit( X_train, y_train, eval_set=[(X_test, y_test)], eval_metric='auc', callbacks=[lgb.early_stopping(50)] ) print(classification_report(y_test, clf.predict(X_test)))两个参数是这套配置的核心。num_leaves 和 max_depth 同时限制模型容量。用户画像的特征维度通常只有十几个,样本量在几百到几万之间,叶子节点开太大必然过拟合。我一般固定 num_leaves=15、max_depth=4,足够表达用户行为组合,又不会把个别异常用户的行为了到极端路径。min_child_samples=20 是另一个核心参数,它要求每个叶子节点至少有 20 个样本,作用相当于给决策树加了一个“代表多数人意见”的约束。
如果样本不平衡严重,光调模型参数不够,还得调预测阈值。默认 0.5 的阈值在正样本只有 20% 左右时会大量漏判。
from sklearn.metrics import precision_recall_curve import numpy as np probs = clf.predict_proba(X_test)[:, 1] prec, rec, ths = precision_recall_curve(y_test, probs) # 找一个F1最大的阈值 f1 = 2 * prec * rec / (prec + rec + 1e-9) best_thr = ths[np.argmax(f1)] print(f"最优阈值: {best_thr:.3f}, 对应F1: {f1.max():.3f}") # 用最优阈值重新预测 y_pred_adjusted = (probs >= best_thr).astype(int)逻辑说明:precision_recall_curve 遍历所有可能的阈值,算出每个阈值下的精确率和召回率,然后找 f1 最大的点。这里我加了个 1e-9 防止除零。实际项目里阈值不只看 F1,还要配合运营成本:如果画像标签是用于推送技术课程,误报打扰用户,可以偏向高精确率;如果是用于全量沉默用户激活,偏召回率更划算。
3.4 多兴趣扩展与用户分群
“对 Python 感兴趣”只是跳出唯一个单分类。CSDN 用户往往同时关注多个方向,正确做法是对每个预定义分类单独训练一个二分类器。代码结构是一个循环。
categories = ['Python', 'Java', '大数据', '前端', '数据库', '人工智能'] models = {} for cat in categories: cat_pos = user_cat_stats[ (user_cat_stats['category'] == cat) & (user_cat_stats['strong_cnt'] >= 2) ]['user_id'].tolist() cat_neg_pool = list( set(active_users) - set( user_cat_stats[user_cat_stats['category'] == cat]['user_id'] ) ) cat_neg = np.random.choice(cat_neg_pool, min(len(cat_pos) * 3, len(cat_neg_pool)), replace=False) cat_df = pd.DataFrame({ 'user_id': cat_pos + list(cat_neg), 'label': [1] * len(cat_pos) + [0] * len(cat_neg) }).merge(user_features, on='user_id', how='left') # 按用户切分 users = cat_df['user_id'].unique() np.random.shuffle(users) tr = users[:int(len(users) * 0.7)] te = users[int(len(users) * 0.7):] model = lgb.LGBMClassifier( n_estimators=100, learning_rate=0.05, num_leaves=15, max_depth=4, min_child_samples=20, random_state=42 ) model.fit( cat_df[cat_df['user_id'].isin(tr)][feature_cols], cat_df[cat_df['user_id'].isin(tr)]['label'] ) models[cat] = model逻辑说明:每个类别独立训练、独立调阈值,互不干扰。最后某个用户拿到的兴趣标签可能是 Python 0.72、数据库 0.46、Java 0.31,按阈值切分后可同时拥有 Python 和数据库两个标签。这样的画像天然就是多值的,不用强行把用户塞进唯一标签。
用户分群的聚类可以作为补充画像维度,但我不建议把它当核心成果。KMeans 只对活跃度、阅读深度等特征做聚类,聚类结果本质上是做了一层描述性标签,不是预测性标签:
from sklearn.cluster import KMeans from sklearn.preprocessing import StandardScaler scaler = StandardScaler() X_scaled = scaler.fit_transform(user_features[feature_cols]) km = KMeans(n_clusters=4, random_state=42, n_init=10) user_features['segment'] = km.fit_predict(X_scaled) # 观察每个分群的特征均值 user_features.groupby('segment')[feature_cols].mean()cluster 数用 4 是经验值,在 CSDN 场景里大致对应:深度技术型、广泛浏览型、轻度互动型、流失边缘型四个群体。n_init=10 是让 KMeans 从 10 个不同初始中心跑十遍取最优,别用默认的随机初始中心,否则结果完全不可复现。
4. 画像服务怎么落地:存储选型、接口封装与更新调度
4.1 存储选型:按用户量级和查询模式定,别一上来就上 HBase
画像建好了得让人用。常见做法是基于用户 ID 查询画像标签,按查询场景选择合适的存储。我见过太多项目一上来就铺 Hadoop 全家桶,最后发现 MySQL 加一个 Redis 缓存就够用了。
| 存储 | 适合规模 | 查询模式 | 典型用途 |
|---|---|---|---|
| MySQL | 万级以下 | 按 user_id 点查 | 画像宽表存储、离线计算中间结果 |
| Redis | 十万级热标签 | KV 快速读取 | 接口层热标签缓存 |
| Elasticsearch | 百万级 | 按标签倒排筛选 | 运营侧“圈选人群”做定向推送 |
| HBase | 千万级以上 | 按 RowKey 点查 | 全量画像列式存储,归档明细标签 |
选型理由很直接:用户画像标签本质是一个 user_id 到一组标签的映射,不涉及复杂关联查询。如果系统还没到百万用户规模,MySQL 加 Redis 是最稳妥的组合。MySQL 负责存储宽表和标签明细,Redis 负责承接在线接口的高频读请求。Elasticsearch 是为“找出所有对 Python 感兴趣的用户”这类运营需求准备的,没有这个需求可以先不接。
4.2 画像接口:Flask 加 Redis 缓存的最小实现
接口层推荐直接给出一套可复现的最小实现。逻辑是接口先查 Redis 缓存,缓存命中直接返回;缓存未命中回源 MySQL,取到后回填 Redis 并设置过期时间。
from flask import Flask, jsonify, request import redis import json import pymysql app = Flask(__name__) r = redis.Redis(host='localhost', port=6379, db=1) def load_profile_from_db(user_id): # 从 MySQL 宽表读出该用户全部标签 conn = pymysql.connect( host='localhost', user='root', password='密码', database='profile_db', charset='utf8mb4' ) cursor = conn.cursor(pymysql.cursors.DictCursor) cursor.execute( "SELECT interest_tags, segment, risk_level, last_settle_ts " "FROM user_profile WHERE user_id=%s", (user_id,) ) row = cursor.fetchone() conn.close() return row if row else None @app.route('/api/profile/<int:user_id>', methods=['GET']) def get_profile(user_id): cache_key = f'profile:{user_id}' cached = r.get(cache_key) if cached: return jsonify(json.loads(cached)) profile = load_profile_from_db(user_id) if profile is None: return jsonify({'code': 404, 'msg': 'profile not found'}), 404 # 标签接口不需要秒级新鲜,5分钟缓存足够 r.setex(cache_key, 300, json.dumps(profile, ensure_ascii=False, default=str)) return jsonify({'code': 0, 'data': profile}) if __name__ == '__main__': app.run(host='0.0.0.0', port=8000)参数说明:缓存过期时间设置为 300 秒,也就是五分钟。用户画像标签一天最多更新一次,五分钟缓存既不会让下游读到的数据过期太久,又能挡住绝大多数重复查询的流量。setex 的好处是 set 和 expire 原子执行,防止只设了值忘了设过期时间导致 Redis 内存被打满。
一个容易踩的细节:接口返回前要对 MySQL 的时间类型字段做default=str,否则json.dumps遇到 datetime 会抛 TypeError。我见过不少新手在联调阶段死在这一行上。
4.3 更新调度:统计标签日更、模型标签周更、时间窗口要统一
画像系统的更新频率不能所有标签一个节奏。统计标签按天更新,凌晨低峰期跑任务,反映的是“昨天为止”的用户状态;规则标签跟着统计标签走,统计宽表算完立刻跑规则引擎;模型标签则按周重训练一次,因为模型参数没必要每天变,训练耗时也更长。
常见做法是直接用操作系统的 cron。如果你有 Airflow 或 DolphinScheduler,只是把启动命令挪进去。
# 每天凌晨2点:更新统计标签与规则标签 0 2 * * * cd /opt/profile && python update_stats.py 0 3 * * * cd /opt/profile && python update_rule_tags.py # 每周日凌晨4点:重训练兴趣模型并更新模型标签 0 4 * * 0 cd /opt/profile && python retrain_interest_model.pycron 里的时间要刻意错开:先算统计标签,再跑规则标签,互相有依赖。重训练丢在周日而不是工作日,是为了避免占用线上数据团队的调休时间,也避免模型一到周一晴天下的更新出现突发事故。
更新脚本里最重要的参数是统计窗口。“近 30 天”这个窗口要在更新脚本和建模脚本里同时保持默认值一致。把窗口抽到一个公共配置里做参数化,是所有中型画像系统必须做的改造:
# config.py from datetime import datetime, timedelta CUTOFF = datetime.now().normalize() WINDOW_DAYS = 30 WINDOW_START = CUTOFF - timedelta(days=WINDOW_DAYS)然后其他脚本统一from config import WINDOW_START, CUTOFF。这样做的好处是改窗口只改一处,不会出现统计标签用的 30 天、模型训练用的 7 天,最终画像三层标签之间互相打架。
如果你手里的源码是从 CSDN 上下载的,第一步不是跑起来,而是先看它的公共配置和 requirements。这类源码多半还停留在 Python 3.6 加旧版 pandas 的环境版本,直接在新环境跑经常在 DataFrame 的 API 上报错。先把数据源路径、日志字段名改成你自己的,模型部分反而最不需要动。
5. 用户画像项目避坑指南:五条高频踩坑记录
5.1 离线准确率高得离谱,上线后立刻缩水
现象:训练集上测试 F1 到 0.9 以上,模型标签上线后运营反馈“完全是乱打标签”。很多从 CSDN 拿到源码跑完出这个结果的人,第一反应是模型参数有问题,实际是数据切分方式错了。
原因:训练测试“撞车”。最常见的是按行为记录直接随机切分,同一用户的多条行为既落进训练集又落进测试集,模型在测试集相当于抄了答案。另一种更隐蔽的是特征窗口包含了目标标签同期的行为,比如用“收藏数”预测“是否收藏”,这属于特征穿越。
解决:一律按 user_id 维度切分。第 3.2 节的写法可以直接套用。同时要检查特征列里有没有混入标签的强相关字段:label 由 strong_cnt 派生,特征列表里就不能再放 collect_cnt 和 comment_cnt,否则就是拿答案预测答案。
5.2 正负样本不平衡,0.5 阈值直接翻车
现象:模型预测结果里绝大多数用户都是负样本,兴趣标签覆盖率不到 5%,画像看起来毫无价值。
原因:正样本定义太严格。比如“收藏与评论总数不少于 2”在活跃用户里可能只占少数,正负比到了 1:10 甚至 1:50,默认 0.5 的决策阈值会把几乎所有用户推入负类。
解决:训练时负采样控制正负比,预测时重新搜索阈值。第 3.2 节的采样代码把正负比固定到 1:3。预测侧用 precision_recall_curve 找 F1 最大的阈值。一个血泪经验是:阈值低于 0.3 是常态,不是异常,不要不敢调低,让模型尽量把候选用户露出,再由规则标签过滤。
5.3 爬虫流量混进画像,标签被污染成“全都要”
现象:某用户画像显示 30 天浏览 2 万篇文章、收藏几百篇、所有技术领域全有标签,明显不符合人类行为。
原因:没有过滤机器行为。CSDN 这类内容平台爬虫很常见,爬虫产生的 browse/采集行为会被日志原样记录,在线计统计、兴趣模型会把爬虫视作超级活跃用户。
解决:清洗阶段加一条硬过滤规则。常见的做法是同时限定时段和速率:单日行为次数超过阈值(如 500)、凌晨 2 点到 5 点持续高频访问、相同两个行为间隔平均小于 2 秒,满足其一视为爬虫,从画像样本中剔除。
# 过滤疑似爬虫:按用户统计单日行为量与平均行为间隔 user_daily_threshold = 500 behavior_stats = ( recent_log.groupby('user_id')['action'].count().reset_index() ) behavior_stats.columns = ['user_id', 'daily_action_cnt'] behavior_stats['is_spider'] = behavior_stats['daily_action_cnt'] > user_daily_threshold # 过滤前看一眼分布,阈值不要拍脑袋 print(behavior_stats['daily_action_cnt'].describe())逻辑说明:硬过滤不要一刀切删除用户,而是把该用户的画像标记上is_spider=True,在后续模型训练样本中排除。因为爬虫行为占比虽然小,但它是极端高频样本,会显著扰动特征分布。
5.4 新用户冷启动,画像覆盖率永远上不去
现象:画像系统上线统计覆盖率只有 60%,剩下 40% 全是注册不足 7 天的用户,没有标签可打。
原因:所有标签都依赖历史行为,新用户的行为积累不够。这属于冷启动问题,从算法层面很难解决,需要在产品侧补策略。
解决:两层方案叠加。第一层用注册信息做先验标签:CSDN 场景里,新用户注册时会选关注领域,这个信息直接转成初始兴趣标签。第二层做“首日行为快速画像”:新用户第一次点击开始,就按点击内容的分类实时累加偏好概率,哪怕只有 3 次点击也能给出一个带置信度的弱标签。
# 首日行为快速画像:用首次登录日的点击行为生成初始标签 first_day = recent_log[recent_log['ts'] >= cutoff - pd.Timedelta(days=1)] first_day_cat = ( first_day.merge(articles, on='article_id', how='left') .groupby(['user_id', 'category']) .size() .reset_index(name='cnt') ) # 按行为占比打弱标签,置信度与总行为量挂钩 first_day_cat['score'] = first_day_cat['cnt'] / first_day_cat.groupby('user_id')['cnt'].transform('sum') first_day_cat['confidence'] = (first_day_cat['cnt'] / 10).clip(upper=1.0)逻辑说明:confidence 用cnt / 10再截断到 1.0,意思是积累 10 次行为算满置信度。冷启动期内这个弱标签直接参与推荐,一周后由统计模型标签覆盖。这个机制不复杂,但它是提升画像覆盖率最有效的手段。
5.5 统计窗口不统一,标签时效像开盲盒
现象:昨天刚更新的兴趣标签,今天接口查出来还是五天前的值。下游推荐系统拿老标签推内容,用户已经换方向了还在推旧知识。
原因:多个脚本各写各的窗口。update_stats.py 用“近 30 天”,模型重训练脚本用“近 7 天”,接口查的数据又来自另一张预聚合表,三处对“最近”的定义不一致。
解决:窗口参数全部收敛到公共配置。第 4.3 节的 config.py 就是标准做法,所有脚本从同一个配置导入WINDOW_START和CUTOFF。同时每张标签表加一个更新时间戳update_ts,下游消费时能看到数据新鲜度。排查问题先查这张表的 update_ts,能省很多时间。
6. 画像效果怎么验证:覆盖率、吻合度与业务闭环
6.1 人工抽检:模型标签的可解释性评估
模型标签上线前做一轮人工抽样评估,样本量不用大,一百到两百个用户足够。做法是随机抽活跃用户,把模型输出的 Top2 兴趣标签打印成表格,由业务人员根据用户的公开行为(历史文章、评论内容)打“符合”或“不符合”,最后统计吻合率。
# 抽100个用户,输出待人工评估的问卷表 sample_users = user_features.sample(100, random_state=7) eval_df = pd.DataFrame({ 'user_id': sample_users['user_id'], 'pred_tag_top1': ['Python'] * len(sample_users), # 示例,实际取模型输出 'pred_pr_top1': sample_users['subject_entropy'].round(3), 'human_label': np.nan }) eval_df.to_csv('manual_eval.csv', index=False) # 人工标注完成读回,统计吻合率 filled = pd.read_csv('manual_eval.csv') hit = ((filled['human_label'] == 1) & (filled['pred_pr_top1'] <= 30)).sum()这个验证动作的最大价值不是数字本身,而是逼你把标签口径落成文字,让人工评估者知道“对 Python 感兴趣”到底意味着什么。
6.2 覆盖率、时效性:两个比准确率更敏感的指标
准确率只解释“打对的标签中对的比例”,但要先回答“这个用户有没有画像”才谈得上对不对。覆盖率定义:有画像标签的用户数除以全部用户数。冷启动阶段,这个指标比准确率更值得日更盯防。时效性定义:标签表中的 update_ts 与当前时间的差距。如果 P95 的标签更新延迟超过 24 小时,应该先查调度链路的延迟。画像是服务,不是模型实验,覆盖率和时效性是它的服务可用性指标。
6.3 画像驱动推荐的 AB 闭环验证
最后验证画像价值的正确姿势是拉一个业务指标来做 AB。简单做法:把画像的“兴趣标签”送进推荐候选集的重排层。实验组用户按画像标签把候选文章按类别加权,对照组只看热度排序。观察点击率和阅读时长。两周后如果实验组点击率显著高于对照组,这套画像才算真正跑通了业务闭环。
以我自己的经验来说,画像系统上线后第一周,先打印一张标签覆盖率日报,再随机抽 30 个用户人工核对标签合理性,比直接调模型参数更有用。很多所谓的模型问题,最后回溯全是数据口径或调度问题。这个顺序能帮你省下至少两周排查时间。希望帮到你。
本文还有配套的精品资源,点击获取