简介:基于Jupyter Notebook的Python用户画像构建源码,面向数据科学家、分析工程师与运营人员,提供一套可直接参考落地的用户画像分析流程。资源以泰坦尼克号数据集为切入,覆盖数据清洗、特征提取、可视化展示与画像建模等环节,能帮助读者理解如何从用户行为与基础属性中提炼标签,支撑产品定位与个性化推荐等场景。压缩包共20个文件,包含13个CSV格式数据文件、4个IPython Notebook核心代码、1个Python脚本及1个PNG图表和1个说明文档,整体大小118.46MB,目录结构区分了画像构建与入门示例模块,便于按需取用。Pandas完成表格处理,Matplotlib/Seaborn绘制分布图,Notebook内代码注释清晰,可结合自身数据替换复用。已有125人学习下载,适合希望快速上手用户画像构建、补齐数据分析实战经验的中级Python用户。
1. 用户画像不是报表:一张标签表背后的数据决策逻辑
做用户画像这几年,最常被问的一句话是:用户画像是不是就是按性别、年龄段拉一张透视表?答案是否定的。透视表回答的是“有多少人”,用户画像回答的是“这些人是谁、接下来会做什么”。运营同学想圈出高价值人群,产品同学想看新老用户的行为差异,算法同学要喂特征,背后的基础都是同一张画像标签表。这篇文章要分享的,就是我用 Jupyter Notebook 搭的一套 Python 用户画像构建源码:从原始订单和访问日志到一张可直接落库的画像宽表,每个单元格都能直接跑、能改参数、能看到中间结果。适合刚入门 python 数据分析、又不想只看视频教程的读者,更适合理清思路后想复现一份完整源码的人。
2. 先把Jupyter Notebook的环境喂饱:从安装到默认保存路径的实战设置
2.1 为什么用户画像开发首选Jupyter Notebook
说句实在话,用户画像这种任务,中间结果特别多。你清洗完要做缺失值统计,分完箱想看每个箱的覆盖人数,打完标签要核对映射逻辑有没有错位。用纯 .py 脚本写当然能跑,但每次验证都要 print 或者把中间表导到 Excel 里翻,效率很低。
Jupyter Notebook 的单元格执行模型天然适合这种逐步调试的活。我一般的做法是:一个单元格放一个处理阶段,上一个阶段的 DataFrame 留在内存里,下一个单元格直接接着用,画图、取样、改类型都在旁边完成。而且 Notebook 本身就带数据结构展示,pandas 的 DataFrame 会渲染成带滚动条的表格,省掉很多head()的输出空间。
另外一个对新手友好的点:标题里的“源码分享”,在 Jupyter Notebook 里面是真正意义上的“源码即笔记”。Code 单元格里写 Python 代码,Markdown 单元格里可以记录当时的判断。跑通一次之后,这份 Notebook 既是代码,又是对外的交付文档。我经常直接把 notebook 导出成 HTML 发给业务方,对方看到的不是一团代码,而是带图带结论的分析过程。
2.2 用Anaconda一次装齐Python数据科学生态
如果你刚装了 python 还没装库,我建议别一个个去 pip install。常见做法是装 Anaconda,它自带 python、Jupyter Notebook、pandas、numpy、matplotlib、scikit-learn 这一整套数据科学常用库。装完之后 conda 管理环境比裸 pip 稳妥很多,尤其是用户画像要用的 scikit-learn 这类带二进制的包,conda 会帮你处理依赖兼容性。
创建一个独立环境,避免把 base 环境搞乱:
conda create -n user_profile python=3.9 -y conda activate user_profile conda install jupyter notebook pandas numpy matplotlib seaborn scikit-learn -c conda-forge -y参数说明:-n user_profile是给环境起名;python=3.9 是我常用的稳定版本,3.10 以上部分旧代码在 pandas 的append方法上有兼容问题;-c conda-forge指定的是社区频道,里面的包通常比默认频道更新,seaborn 这类绘图库建议从这里装。装完后在终端敲jupyter notebook,能启动就说明环境通了。
2.3 调整Jupyter Notebook默认保存路径与工作目录
热搜里“jupyter notebook默认保存路径”这个问题,几乎每个用过 Notebook 的人都会遇到一次。默认情况下,启动 Notebook 之后,文件树停留在你启动命令所在的那个目录。如果把 notebook 文件放在其他盘,每次都要一层层点进去,还经常出现“我在路径 A 启动,却找不到路径 B 里的 ipynb”的困惑。
我一般会固定一个工作目录,比如D:\projects\user_profile。先改配置:
jupyter notebook --generate-config执行后会生成一个jupyter_notebook_config.py文件,Windows 下通常在C:\Users\你的用户名\.jupyter\目录里。编辑这个文件,把这一段改掉:
# c.NotebookApp.notebook_dir = '' c.NotebookApp.notebook_dir = 'D:/projects/user_profile'注意这里用的是正斜杠。改完保存,重新启动 Jupyter Notebook,左侧文件列表直接就是你指定的画像项目目录。还有一个更省事的启动方式:在 Windows 的快捷方式里把“起始位置”填成目标目录,不用改配置也能定位到那里。不过对公司电脑,改配置一次生效更省心。
提示:如果找不到配置文件,可以执行
jupyter --config-dir,终端会打印真正的配置目录,以这个输出为准。
2.4 中文字体和绘图负号设置
用户画像的结果图里免不了要写“消费金额”“活跃天数”这些中文标签,matplotlib 默认字体对中文支持很差,不设置的话出来全是方块。这个坑很小,但几乎每个初学 python 画图的人都会碰上。
在 Notebook 开头统一设置一次:
import matplotlib.pyplot as plt plt.rcParams['font.sans-serif'] = ['Microsoft YaHei', 'SimHei', 'Arial Unicode MS'] plt.rcParams['axes.unicode_minus'] = Falsefont.sans-serif是字体回退列表,Windows 用的是 Microsoft YaHei,macOS 会走 Arial Unicode MS,Linux 上如果有文泉驿就写 WenQuanYi Micro Hei。axes.unicode_minus设为 False,是为了避免坐标轴负号显示成方块。这个设置对所有 matplotlib 绘图单元格生效,属于“一次配置,全文复用”的典型。
3. 用户画像构建主流程:一份可以照着跑的数据清洗与标签生成源码
3.1 数据加载:从CSV/Excel到DataFrame的第一步
用户画像的数据源很少是现成的标签表,更多时候你拿到的是订单明细、访问日志、会员资料这三类原始表。第一步不是马上写特征,而是先把数据读进来,确认体量和字段类型。这里以两份模拟数据为例:一份是用户订单表orders.csv,字段包含user_id、order_time、amount、category;另一份是用户基本信息users.csv,包含user_id、gender、age、register_time。
读数和初检的代码:
import pandas as pd orders = pd.read_csv('orders.csv', parse_dates=['order_time']) users = pd.read_csv('users.csv', parse_dates=['register_time']) print(orders.shape, users.shape) print(orders.info()) print(orders['user_id'].nunique(), users['user_id'].nunique()) print(orders['category'].value_counts().head(10))参数说明:parse_dates=['order_time']让 pandas 在读取时把订单时间转成 datetime64 类型,这一步直接影响后面按小时、按月份聚合的效率;nunique()用来核对两个表的用户数是否对得上,一般 order 表的用户数会小于等于 users 表。用户数对不上是常态,对不上不要慌,先看是独有还是缺失,后面清洗再做取舍。category的 value_counts 能看到商品类目的分布情况,方便判断有没有冷门类目需要合并。
这里想多提一句:尽量在读取阶段就把解析时间、指定编码做完。CSV 文件如果是从业务系统导出的,经常是 GBK 编码,这时候要写成pd.read_csv('orders.csv', encoding='gbk'),否则会报 UTF-8 解码错误。这也是报错里最常见的“黑匣子”之一。
3.2 数据清洗:缺失值、重复值与时间字段解析
加载完数据,紧跟着就是清洗。这一步决定了后面的标签质量,也是整个构建过程里最花时间的一步。
# 丢弃完全没有价值的空行和整行重复 orders = orders.dropna(how='all') orders = orders.drop_duplicates(subset=['user_id', 'order_time', 'amount']) # 处理关键字段缺失 orders = orders.dropna(subset=['user_id', 'amount']) users = users.dropna(subset=['user_id']) # 过滤明显异常值:金额为负的订单 orders = orders[orders['amount'] > 0] # 时间字段补全:把字符串统一成日期格式 orders['order_date'] = orders['order_time'].dt.date # 年龄分档前先做边界压缩,避免极端年龄影响分箱 users['age'] = users['age'].clip(0, 100)代码逻辑说明:dropna(how='all')删除所有字段都是 NaN 的行,这种空行经常出现在从 Excel 复制过来的数据里;drop_duplicates按业务主键去重,订单表里同一用户在同一个时刻买了同一金额的东西,继续保留没有意义。年龄做clip(0, 100)是为了后续pd.cut分箱时不被 -1 或 999 这种脏数据带偏。
清洗后建议马上做一个汇总检查:orders.isna().sum()和users.isna().sum(),看每个字段还剩多少缺失值。缺失率过高的字段可以放弃,过低的用填充或均值补。用户画像里最忌讳“为了填缺失而填缺失”,比如性别缺失 50% 还硬补成“未知”,这会让后面的交叉分析失去区分度。这一步是后人最容易翻车的地方。
3.3 特征工程与标签映射:把行为数据变成画像标签
清洗完之后,进入核心的特征构造阶段。用户画像标签里最常用的是 RFM 思路的简化版,R(最近一次消费时间)、F(消费频次)、M(消费金额)。这三个指标只需要订单表就能算出来,也是很多公司量化用户价值的基础。这种构造特征的方式,和 python 量化交易策略代码里构造因子很像,先定义原始指标,再切分或映射成离散标签。
import datetime # 确定统计截止日:取订单表最大日期后一天,避免 R 值出现 0 天 ref_date = orders['order_time'].max() + datetime.timedelta(days=1) rfm = orders.groupby('user_id').agg( last_order_date=('order_time', 'max'), order_count=('order_time', 'count'), total_amount=('amount', 'sum') ).reset_index() rfm['recency'] = (ref_date - rfm['last_order_date']).dt.days rfm['avg_amount'] = rfm['total_amount'] / rfm['order_count']参数说明:agg里用last_order_date、order_count和total_amount是自定义的列名,后面要直接作为特征进模型,建议起一个可读性强的名字。ref_date我习惯取最大订单日期加一天,这样新用户当天买过东西时,计算出来的 recency 是 1 而不是 0,后面分箱时边界更干净。
标签映射通常是用户画像最后一步:把连续值切成业务词汇。比如把消费金额分为低、中、高三个档位,把活跃度分为沉默、低频、活跃、高频四个档位。
# 金额分层:按分位数切分更贴近数据分布 amount_bins = [-1, 0, 500, 2000, float('inf')] amount_labels = ['未消费', '低消费', '中消费', '高消费'] rfm['amount_tier'] = pd.cut(rfm['total_amount'], bins=amount_bins, labels=amount_labels) # 活跃度分层:先按消费次数分箱,再做业务映射 rfm['active_level'] = pd.cut( rfm['order_count'], bins=[0, 1, 3, 8, float('inf')], labels=['沉默', '低频', '活跃', '高频'] )pd.cut的 bins 参数含义是左开右闭区间,注意第一个区间要从 -1 开始,才能把 0 值样本放进去。labels 必须比 bins 少一个,否则会抛 ValueError。这里选择分位数还是固定阈值看业务习惯,我一般先用describe()看分布,再人工定边界。标签名称最好和业务方对齐,你要叫“高消费”,运营同学默认的“高消费”可能是另一个口径,同一个词两种口径,比不做画像还麻烦。这里的活跃度口径如下:
| 标签 | 区间 | 订单数 | | 沉默 | (0, 1] | 1单 | | 低频 | (1, 3] | 2-3单 | | 活跃 | (3, 8] | 4-8单 | | 高频 | (8, ∞) | 9单及以上 |
3.4 画像宽表输出:把每类标签合并成一个用户ID一行
画像构建的最后一步是宽表合并。很多分析场景下,模型需要每个用户一行、每列是一个特征或标签的表结构。把用户信息表、RFM 特征表、类目标签表横向拼接起来。
# 每个用户最常购买的类目 top_category = ( orders.groupby(['user_id', 'category']) .size() .reset_index(name='cnt') .sort_values(['user_id', 'cnt'], ascending=[True, False]) .drop_duplicates(subset=['user_id']) .rename(columns={'category': 'top_category'}) ) # 合并宽表 profile = users.merge(rfm, on='user_id', how='left') profile = profile.merge(top_category[['user_id', 'top_category']], on='user_id', how='left') # 导出成品 profile.to_csv('user_profile_wide_table.csv', index=False, encoding='utf-8-sig')逻辑说明:sort_values之后接drop_duplicates(subset=['user_id']),可以拿到每个用户购买次数最多的类目,这个写法比用rank()再筛选直观得多。merge默认是 inner join,这里两个表都显式写了how='left',以 users 表为准,保留所有注册用户。最后的to_csv用encoding='utf-8-sig',是 Excel 直接打开不乱码的关键参数。
4. 用图表验证画像质量:分布、交叉和TopN的可视化源码
4.1 单画像维度的分布可视化
用户画像做到后面,最怕的一件事就是“标签做出来了,但没人信”。这时候最好的应答方式不是坐在座位上解释,而是直接画出分布图。图表是验证画像质量的第一道关。
import matplotlib.pyplot as plt import seaborn as sns fig, axes = plt.subplots(1, 3, figsize=(16, 4)) sns.histplot(profile['total_amount'], bins=50, ax=axes[0], color='#2a6f97') axes[0].set_title('消费金额分布(对数坐标)') axes[0].set_xscale('log') sns.countplot(data=profile, x='active_level', order=['沉默', '低频', '活跃', '高频'], ax=axes[1], palette='Blues') axes[1].set_title('活跃度标签人数') sns.boxplot(data=profile, x='amount_tier', y='avg_amount', ax=axes[2]) axes[2].set_title('消费分层客单价对比') plt.tight_layout() plt.show()这里的参数说明:set_xscale('log')是处理金额长尾分布的关键,用户消费金额大部分集中在低位,少数人贡献高额,不用对数坐标,直方图左端会积压成一根柱子。countplot里的order参数强制按业务顺序排列,否则 seaborn 会按字母序排出低频、沉默、活跃、高频,观感很乱。第三张图用 boxplot 来看消费分层和客单价的关系,如果高消费组的客单价反而比中消费组低,说明你分层衡量的可能不是消费能力,而是消费频次。
4.2 画像交叉分析:看两列标签的组合规律
单维度分布只能说明标签自身合理,交叉分析才能看出业务组合。用户画像里最常用的是新老用户和活跃度、性别和消费层级这两对交叉。这里用透视表和热力图实现性别和消费层级的组合观察。
cross = pd.crosstab(profile['gender'], profile['amount_tier']) cross_norm = cross.div(cross.sum(axis=1), axis=0) # 行归一化,看组内占比 plt.figure(figsize=(8, 5)) sns.heatmap(cross_norm, annot=True, fmt='.1%', cmap='YlGnBu', linewidths=0.5) plt.title('性别 × 消费分层占比热力图') plt.show()说明:pd.crosstab是用户画像里绕不开的函数,第一个参数是行,第二个是列。cross.div(cross.sum(axis=1), axis=0)做的是行归一化,计算的是“每个性别组内,各消费层级的占比”,而不是全局占比,这两个口径含义完全不同。annot=True在热力图上显示数字,fmt='.1%'让数字以百分比展示。如果性别字段有缺失,crosstab 默认会忽略 NaN,建议先用profile['gender'].fillna('未知')补上,避免前面几万条用户在这张图里凭空消失。
4.3 把图表输出成图片和报表文件
分析结论最终要落地到报告里。Jupyter Notebook 里在浏览器里看得到,不代表别人看得到。我一般会同时输出图片和汇总表,既方便粘到 PPT 里,也方便给业务方做二次加工。
fig.savefig('output/profile_distribution.png', dpi=150, bbox_inches='tight') report = ( profile.groupby(['active_level', 'amount_tier']) .size() .reset_index(name='user_cnt') ) report.to_excel('output/profile_cross_report.xlsx', index=False)参数说明:dpi=150基本满足绝大多数汇报场景的清晰度;bbox_inches='tight'会自动裁剪空白边缘,避免图片四周留白过多。Excel 输出尽量用to_excel而非to_csv,因为运营同学拿到 CSV 用 Excel 打开时,长数字 ID 会被转成科学计数法,还改不回来。如果必须用 CSV,记得在前面把user_id读成字符串或设置csv的 quoting。
4.4 生成典型用户的画像卡片描述
有宽表、有分布图之后,业务方其实还想得到更直观的“一个人”。我常做的是从画像宽表里随机抽几个用户,把标签组合成一段可读的画像描述。这一步不算什么高深技术,但对汇报和拉齐认知特别有用。
def describe_user(row): return (f"用户{row['user_id']},性别{row['gender']},年龄{row['age']}岁," f"累计消费{row['total_amount']:.0f}元,共{row['order_count']}单," f"最近一次购买在{row['recency']}天前,属于{row['active_level']}用户," f"偏好类目是{row['top_category']}。") sample = profile.sample(n=5, random_state=42) for _, row in sample.iterrows(): print(describe_user(row))这里的random_state=42控制随机种子,让每次运行抽到的是同一批用户,方便对比前后两次代码改动带来的标签差异。describe_user只做字符串拼接,所有字段在之前已经补全,这里不处理缺失值。抽样的目的不是做统计推断,而是让业务方感知到“咦,这张表里真的能读出一个个具体的人”。
5. Jupyter Notebook运行Python用户画像源码的5个高频避坑
5.1 现象:内核莫名其妙反复死掉,最后连保存都点不动
在做用户画像时,最典型的一幕是:orders表有上千万行,我直接在单元格里执行orders.head(1000000),然后内核直接死亡。原因不是你的代码写错,而是 Notebook 把整个 DataFrame 的渲染结果都塞进了前端输出区,内存和浏览器交互线程双双爆掉。
解决的方式不是不用 Notebook,而是先裁剪再展示。我现在的习惯是:数据量大时先执行orders = orders.sample(10000, random_state=1)做一次抽样验证逻辑,全部代码调通之后再放到全量数据上跑。另一个容易踩的是循环里反复print大数组,改成每个循环内只打印关键统计量。
5.2 现象:代码改了好几遍,跑出来的结果却一模一样
这个问题在 Notebook 里出现的频率高得离谱。原因很常见:同一个变量名在不同单元格被反复赋值,但你没有按顺序执行。用户画像里最容易遇到的就是profile这个中间表,你可能先在第 4 个单元格定义它,又跑到第 8 个单元格做 merge,之后你回头去改第 4 个单元格的筛选逻辑,却不重新执行它,后续单元格接着旧数据的余温在跑。
解决的办法有两个层面。第一,养成从顶部执行到底部的习惯,用菜单里的 Restart Kernel and Run All,保证单元格执行顺序和代码逻辑顺序一致。第二,把影响后续结果的 key 单元格独立出来,例如数据加载、清洗过滤、label 映射各占一个单元格,并在 Markdown 里写醒目标注。命令行有个小技巧,写行号时在代码块上方放一句注释“从这里开始重新执行”,提醒自己不要跨单元格依赖旧状态。
5.3 现象:用户画像图里中文全变成方块,X轴坐标还冒出一道道横线
这个坑我在 2.4 已经给了配置代码,这里补充一个更隐蔽的版本。有时候你设置了rcParams,但后来又调用了 seaborn,seaborn 内部的样式会覆盖之前的字体配置。而且中文乱码不只在 matplotlib 出现,to_csv导出后用 Excel 打开也是乱码,这两个是完全不同的原因:前者是字体缺失,后者是编码问题。
解决方案:字体问题在导入 seaborn 之后再设置一次 rcParams;编码问题统一用encoding='utf-8-sig'导出。如果真的不想每次写两遍,把我上面那段配置单独存一个profile_style.py,在 notebook 开头%run profile_style.py一次性执行。别小看这几行配置,多少人其实不是不会画图,是栽在字体上。
5.4 现象:在Jupyter里新建的文件莫名其妙保存到别的路径
“Jupyter Notebook默认保存路径”这个热搜词的背后,就是大家碰到了同一件事:用浏览器文件树上传/新建的 notebook,不落在你想让它落的目录。根因在于,Notebook 保存文件的根目录,和你启动 Jupyter 时的工作目录一致,而不是你双击某个 .ipynb 文件时的所在目录。
解决方式已经在 2.3 里写过了,核心就两步:jupyter notebook --generate-config生成配置,然后改c.NotebookApp.notebook_dir。这里再补一个细节:修改完配置之后,要彻底关闭终端进程再重新启动,不是只关浏览器标签页,否则配置不会重载。很多人在这里“改了没用”就是这个原因。
5.5 现象:用户画像的时间字段总是解析失败,或者算出来的活跃天数全是负数
时间字段是用户画像的重灾区。现象通常有两种:一是parse_dates报错说时间字符串格式不一致,二是 recency 计算出来是负数。原因很一致——业务系统导出的时间字段里,混着2024-05-01 10:00:00、2024/05/01、05-01-2024乃至带时区后缀的格式。
解决建议:先orders['order_time'] = pd.to_datetime(orders['order_time'], errors='coerce', format='mixed')。errors='coerce'会把无法解析的时间转成 NaT,之后再统一填充或剔除;format='mixed'是 pandas 2.0 之后支持的混合格式识别参数。recency 为负数的另一个常见原因是系统时钟错位或埋点时间戳是未来时间,不要只靠肉眼判断,直接用rfm[rfm['recency'] < 0]筛出来看时间值。数据时间解析这件事,真的值得你花 15 分钟把全表值域扫一遍。
6. 把源码从Notebook搬到生产:函数化改造与定时更新的进阶技巧
用户画像在 Notebook 里跑通之后,接下来要面对的是每天甚至每小时的更新问题。不可能每次都手动打开 Jupyter Notebook 一个单元格一个单元格地执行。我的做法是把代码整理成可复用的函数,用“脚本 + 参数”的方式去跑批。
改造的思路是把第 3 章里的数据处理步骤,拆成load_data()、clean_orders()、build_rfm()、add_customer_info()四个函数,入口统一成一个run_all(data_path, output_path)。代码形态上和 Notebook 里一致,但把路径和阈值参数化成函数的入参。这一步迁移成本很低,因为 Notebook 里本身就是逐步验证过的代码,直接提取即可。
如果公司有调度平台,更稳定的一条路是把 Notebook 导出成 .py。具体操作是:File → Download As → Python (.py),然后把里面的单元格代码合并到一个带main()的脚本里。导出后需要手动删掉get_ipython().run_cell_magic这类的魔法指令,以及%matplotlib inline这类只在交互环境生效的声明。这里有个我经常踩的教训:导出前要把 Notebook 从上到下完整执行一遍,保证代码顺序和变量状态一致,否则导出的脚本会丢失某个中间变量。
至于验证方法,我的建议是每次改造后先跑一份历史数据,和旧 Notebook 的输出做比对。比对方式很简单,把老结果的user_id和total_amount两个字段取出来,和新脚本按user_idmerge,再查abs(差值) > 0.01的行数。如果差异为 0,才算过关;有差异,优先检查是不是 float 精度问题或排序引起的聚合顺序问题。
最后分享一个习惯:用户画像源码里,最容易出问题的永远不是模型和算法,而是数据口径。我给自己定了一条规矩,任何标签定义都写进代码注释并同步到业务文档,比如“高消费=历史累计消费金额大于2000元”,避免过三个月自己都忘了当初为什么这么划分。这条规矩救过我很多次,希望帮到你。
本文还有配套的精品资源,点击获取