简介面向数据分析师与用户研究人员的Python用户画像构建源码依托Jupyter Notebook环境覆盖用户行为数据清洗、特征提取、可视化呈现等完整流程可直接用于电商、内容平台等场景的画像建模练习。资源包共20个文件压缩包大小118.46MB包括13个CSV数据文件、4个IPython Notebook代码文件、1个Python源文件、1个PNG图像及1个Markdown文档其中CSV文件提供原始用户行为记录Notebook展示分步分析过程PNG用于呈现画像结果或流程关系图整体结构清晰。目前已有125人学习代码中综合运用Pandas、Matplotlib/Seaborn等常用库并配有项目说明文档便于系统理解从数据导入到画像输出的完整链路。适合希望掌握用Python构建用户画像、提升用户行为分析能力的数据从业者参考与二次开发。1. 用户画像为什么值得自己动手从“做了个标签表”到“能解释业务变化”接到一个用 Python 在 Jupyter Notebook 里从零构建用户画像的需求时多数人的第一反应是找现成的用户画像源码包跑完出一张表就算交差。但真正落过地的人都知道画像不是“把用户ID和标签拼在一起”而是要把订单、浏览、售后这些原始数据清洗成能解释业务变化的特征。常见的坑有三个数据字段对不上、标签口径拍脑袋、跑完的代码三个月后没人看得懂。这篇我把一套能直接复现的构建流程拆开讲从环境准备到 RFM 分箱、标签宽表输出再到排错清单新手可以照着敲熟手可以跳过安装直接看参数和边界。重点是让你做完之后能跟业务方说清楚每一个标签是怎么算出来的。2. 先把环境跑起来Jupyter Notebook 与 Python 的选型、安装与目录规划2.1 为什么用户画像构建选 Jupyter Notebook而不是直接写一个 .py 脚本用户画像构建本质上是一个探索性数据分析过程不是你写完代码一次跑完就结束的。你会反复做这几件事读进来一批数据看一眼分布发现字段有问题改清洗逻辑重新跑一段再看结果。这个循环在 Jupyter Notebook 里天然顺手因为每个 cell 的中间结果都存在内核里你可以单独重跑某一步不用每次都从头执行。对比直接在 VSCode 里写一个长脚本改一行后面全部重跑调试成本高很多。另外一个实际好处是可视化内嵌。画像特征做出来之后画分布图、柱状图、雷达图图直接出现在 cell 下方方便你一边看结果一边调整分箱阈值。如果是纯脚本你还得额外配置 matplotlib 的显示后端或者把图存成文件再打开。对于需要频繁看中间结果的项目阶段这种交互方式能省下不少时间。更重要的是Notebook 本身是 JSON 格式天然带输出结果项目做完之后把 .ipynb 文件发给同事对方打开就能看到每一步的产出而不是拿到一堆 CSV 和代码对不上号。有人会问那生产环境怎么办这个后面会讲。画像构建阶段用 Notebook 探索确认口径之后把核心逻辑抽成 .py 模块再交给定时任务调度这是最常见的成熟用法。两者不冲突。2.2 从零装一套能直接用一年的 Python 数据环境如果机器上已经装过 Python 3.9 以上版本直接用 pip 装齐依赖就行这是最轻量的路径# 用 pip 安装构建用户画像所需的核心库 pip install jupyter pandas numpy matplotlib seaborn openpyxl参数说明pandas 负责表格数据处理numpy 是底层数值计算matplotlib 和 seaborn 用于画标签分布图openpyxl 是 pandas 导出 Excel 时的可选依赖。如果你是第一次装建议把 pip 换成 pip3 或 python -m pip避免不同 Python 版本之间装错位置。如果你不想手动维护依赖关系直接装 Anaconda 发行版更省心它自带 Jupyter Notebook、pandas、numpy 等常用库。安装完之后在命令行启动# 启动 Jupyter Notebook 服务 jupyter notebook启动成功后会打印一串 URL浏览器会自动打开网页版操作界面默认地址是 http://localhost:8888。如果你是在远程服务器上跑把命令改为 jupyter notebook --ip0.0.0.0 --port8888再在本地浏览器里访问对应的地址即可。这里说一句绑定 0.0.0.0 时务必确认服务器的防火墙只放行了可信来源Notebook 默认没有强认证。端口被扫描到的话别人能直接拿到你的终端权限。密码认证至少要设一个用 jupyter notebook password 就能配。2.3 画像项目的目录规划别让所有文件堆在一个文件夹里用户画像项目通常包含原始数据、中间产物、输出结果、Notebook 和可复用的公共函数混在一起后面找东西会非常痛苦。我第一次做的时候所有文件平铺在一个目录到第八个版本的时候已经分不清哪个是最终稿。后来养成的习惯是先建一套固定目录# 创建用户画像项目的标准目录结构 mkdir -p user_profile_project/{data/raw,data/processed,notebooks,scripts,output}目录用途说明data/raw 放上游导出的原始数据只读不改data/processed 存放清洗后的中间文件比如去重后的订单明细、RFM 特征表notebooks 放分步探索的 .ipynb 文件scripts 放抽出来的公共函数模块比如数据连接、日期处理output 放最终交付的画像宽表。这样分完以后每一步的产出对应一个位置后面接手的同事不需要靠猜就能找到东西。还有一件事值得做在 data/raw 里加一个 README.md记录每个数据文件的来源、导出时间避免三个月后拿到新数据不知道怎么对齐口径。3. 在 Jupyter Notebook 里把用户画像跑通标签体系、RFM 与宽表输出3.1 标签体系设计画像的“词典”长什么样开始写代码之前先把标签体系想清楚。我见过不少项目用户画像做成一张 200 列的表看着很专业实际业务方根本不知道怎么用。真正能落地的画像标签至少要满足两个条件一是每个标签能对应到一个业务动作比如“沉睡用户”对应召回策略二是每个标签有明确的计算口径能溯源到原始字段。基于这两个原则我一般会把标签体系定义为三层结构。第一层是用户基础属性比如性别、年龄段、城市等级这些来自注册信息和订单收货地址主要用于人群透视。第二层是消费行为特征这是画像的核心通常包括最近一次购买时间间隔Recency、购买频次Frequency、消费金额Monetary也就是 RFM 模型的三个维度。第三层是偏好特征比如品类偏好、渠道偏好、活跃时段这类标签可以帮运营做个性化推送。代码里用字典维护这个层级关系看结构一目了然# 标签体系字典层级 - 标签列表 tag_system { 基础属性: [性别, 年龄段, 城市等级], 消费行为: [R间隔, F频次, M金额, 消费等级], 偏好特征: [品类偏好TOP1, 渠道偏好] } # 打印出来核对一遍确保每个标签都能说清计算依据 for level, tags in tag_system.items(): print(f[{level}]) for tag in tags: print(f - {tag})这里有个容易被忽略的点标签不是越多越好是越“可计算、可解释”越好。你在设计阶段就要对每个标签下一个定义比如“R间隔 观察日 - 用户最近一次下单日期按天取整”。定义写不出来或者数据里没有对应字段这个标签就不要放进体系里。另一个常见做法是先用小样本把标签跑出来找业务方确认这些标签能不能解释他们熟悉的用户群体再决定是否全量计算。这一步看起来费时间但能避免你花费几天时间构建出一堆没人用的字段。3.2 从订单明细到 RFM 特征数据清洗与聚合标签体系定好之后开始处理原始数据。这里我构造一份模拟的订单数据用于演示实际项目里这一步通常是 pd.read_csv() 读取上游导出的文件import pandas as pd import numpy as np # 设置随机种子保证每次运行结果一致方便复现 np.random.seed(42) user_ids np.arange(1, 501) n_orders 3000 # 构造模拟订单数据500个用户、3000条订单、近14个月分布 df_order pd.DataFrame({ user_id: np.random.choice(user_ids, n_orders), order_date: pd.to_datetime(2024-01-01) - pd.to_timedelta( np.random.randint(0, 400, n_orders), unitD ), amount: np.round(np.random.lognormal(4, 1, n_orders), 2) }) print(df_order.head()) print(f订单总数: {len(df_order)}, 覆盖用户数: {df_order[user_id].nunique()})接着做清洗。数据清洗是画像构建中最花时间的一步也是影响标签质量最关键的一步。我会把清洗步骤拆成独立的 cell方便单独调试和重跑# 第一步去重。同一个用户同一时间同一金额的订单视为重复记录 df_order df_order.drop_duplicates( subset[user_id, order_date, amount] ) # 第二步删除关键字段为空的记录 df_order df_order.dropna(subset[user_id, amount]) # 第三步确保日期列是 datetime 类型否则后面减法会出问题 df_order[order_date] pd.to_datetime(df_order[order_date]) print(f清洗后订单数: {len(df_order)}) print(f日期范围: {df_order[order_date].min()} ~ {df_order[order_date].max()})参数说明drop_duplicates 的 subset 参数接收一个列表表示按照哪些列判断重复这里用的是三个字段的组合判断dropna 的 subset 也是列表只检查指定的关键字段其他字段为空暂不处理。这里有一个隐含的设计决策观察日取订单数据的最大日期加一天而不是取“今天”这样整个画像结果是基于历史数据的确定性计算什么时间重跑结果都一样。接下来计算 RFM 三个核心指标# 观察日定为订单最大日期 1天保证计算口径稳定 obs_date df_order[order_date].max() pd.Timedelta(days1) df_rfm df_order.groupby(user_id).agg( recency(order_date, lambda d: (obs_date - d.max()).days), frequency(order_date, count), monetary(amount, sum) ).reset_index() print(df_rfm.describe())这个聚合逻辑是整个画像的核心值得仔细说明。groupby(user_id) 是按用户分组agg 左侧的 recency、frequency、monetary 是结果列名右侧的 (order_date, lambda d: ...) 表示对 order_date 这一列应用指定的聚合函数frequency 用的 count 统计订单量monetary 用的 sum 累加消费金额。lambda 函数里 d.max() 取该用户最近一次订单日期(obs_date - d.max()).days 换算成整数天数表示用户已经多少天没有下单了。这个值越小说明用户越活跃。3.3 分箱打分与画像宽表从三个数字到一行画像RFM 算出来之后是连续数值没法直接当标签用。常见的做法是按分位数分箱把数值映射到离散等级。这里使用 pd.qcut 做等频分箱即每一箱包含数量大致相同的用户# 将R值天数分成4段天数越小越活跃 df_rfm[R_level] pd.qcut( df_rfm[recency], 4, labels[高活跃, 中高活跃, 中低活跃, 沉睡] ) # 将F值频次分成4段次数越多等级越高 df_rfm[F_level] pd.qcut( df_rfm[frequency], 4, labels[低频, 中频, 高频, 超高频率], duplicatesdrop ) # 将M值金额分成4段金额越高等级越高 df_rfm[M_level] pd.qcut( df_rfm[monetary], 4, labels[低客单, 中低客单, 中高客单, 高客单], duplicatesdrop ) print(df_rfm.head())参数说明qcut 的第一个参数是待分箱的序列第二个参数是箱数labels 用于指定每个区间的名称duplicatesdrop 的意思是当数据分布不均匀导致分位点出现重复值时自动减少箱数而不是报错。这里要特别留意 R_level 的标签顺序recency 天数越小代表越活跃所以第一个区间对应“高活跃”。如果你把 labels 顺序写反整个分层就会逆转这是 RFM 模型最容易踩的坑之一。接下来把用户的基础信息表与 RFM 特征合并形成一份完整的画像宽表。为了演示构造一个简化的用户注册信息表# 构造模拟用户注册信息1000个用户的基础属性 df_user pd.DataFrame({ user_id: np.arange(1, 1001), gender: np.random.choice([男, 女, 未知], 1000, p[0.45, 0.48, 0.07]), age: np.random.randint(18, 60, 1000) }) # 左连接以用户表为基准缺失的RFM字段会变成NaN df_profile df_user.merge( df_rfm, onuser_id, howleft ) # 给用户分配年龄段方便后续做人群透视 df_profile[age_level] pd.cut( df_profile[age], bins[0, 25, 35, 45, 100], labels[18-25, 26-35, 36-45, 46], rightFalse ) print(df_profile.head())参数说明merge 的 on 参数指定连接键howleft 表示左连接保留 df_user 中的所有用户。这里有个细节上一段代码用 np.random.choice 从 501 个用户中抽样导致部分注册用户没有订单这些用户的 RFM 字段就是 NaN。构建画像时这部分用户单独标记为“注册未购买”不要直接丢弃。age_level 用 pd.cut 做等距分箱参数 bins 指定分组边界rightFalse 表示分组区间左闭右开。最后一步是把画像宽表输出到文件供业务团队取用# 导出画像宽表到CSVutf-8-sig编码保证Excel直接打开不乱码 df_profile.to_csv( output/用户画像宽表.csv, indexFalse, encodingutf-8-sig ) # 同时输出一份字段说明表 field_desc pd.DataFrame({ 字段名: df_profile.columns, 说明: [ 用户唯一标识, 性别, 年龄, 最近一次购买间隔(天), 购买次数, 累计消费金额, R维度分层标签, F维度分层标签, M维度分层标签, 年龄段标签 ] }) field_desc.to_csv(output/字段说明.csv, indexFalse, encodingutf-8-sig)这里输出的两份文件一份是结果数据一份是字段说明。很多项目交付画像表时只给数据不给说明业务方拿到 10 个字段根本不知道 R_level 和 recency 的区别。多花一分钟导出字段说明表能省掉后面大量解释和扯皮的时间。输出编码用 utf-8-sig 而不是 utf-8是因为 Excel 打开 UTF-8 无 BOM 的 CSV 时中文会乱码加了 BOM 头就不会。在 Jupyter Notebook 里跑完整个流程后建议顺手把结果分布用图表展示出来确认数据形态符合预期import matplotlib.pyplot as plt # 让matplotlib正常显示中文标签 plt.rcParams[font.sans-serif] [SimHei] plt.rcParams[axes.unicode_minus] False # 查看RFM各等级的分布 df_rfm[R_level].value_counts().plot(kindbar, titleR等级用户分布) plt.show()这段可视化代码放在 Notebook 里作用是快速检查分箱是否合理。理想情况下四个等级的用户数量大致接近如果某一个等级数量悬殊说明 qcut 的等频分箱没有按预期工作需要回头检查数据中是否有异常值或者重复分位点。SimHei 是 Windows 自带的中文字体在 Linux 服务器上跑需要换成其他中文字体具体排错方法在下一章展开讲。4. 用户画像落地避坑指南5 个让结果翻车的常见问题与排查4.1 重启内核后一切白算中间结果全丢现象在 Notebook 里按顺序跑完所有 cell中途有事离开回来发现内核断开了重启之后所有变量清空只能从头重跑。如果原始数据很大清洗过程很慢这一步浪费二十分钟算轻的。原因Jupyter Notebook 的变量存在内核内存里内核重启或者笔记本关闭再打开内存中的 DataFrame 全部释放。很多人误以为保存 .ipynb 文件会连结果一起保存下来实际上保存的只是代码和输出变量本身不持久化。解决在每个耗时步骤后面把中间结果落盘推荐用 pickle 或 parquet 格式速度比 CSV 快很多# 把清洗后的订单表落盘避免内核重启后重新清洗 df_order.to_pickle(data/processed/订单清洗.pkl) # 把RFM特征表落盘后续分箱调试不用重新聚合 df_rfm.to_pickle(data/processed/RFM特征.pkl)下次打开 Notebook 的时候从落盘处直接读入即可不用重跑前面的步骤。养成这个习惯还有一个额外好处你把 Notebook 拆成“数据读取”“特征构建”“分标签”多个阶段方便单独调试排错效率高很多。4.2 读取 CSV 中文乱码编码问题现象pd.read_csv(订单数据.csv) 一读进来所有中文列名和内容显示成乱码或者直接报错 UnicodeDecodeError: utf-8 codec cant decode byte 0xd5 in position 0。原因上一手导出 CSV 的时候用的是 GBK 编码而 pandas 默认用 UTF-8 解码两边对不上。这在 Windows 环境下导出的中文数据里极其常见。解决先尝试用 GBK 编码读取如果数据文件混合了多种编码导致读取失败加上 errorsignore 参数跳过无法解码的字节但这样可能会丢字符需要人工核查# 优先尝试常见中文编码 try: df pd.read_csv(data/raw/订单数据.csv, encodinggbk) except UnicodeDecodeError: df pd.read_csv(data/raw/订单数据.csv, encodingutf-8, errorsignore)这里有个配套的输出习惯写 CSV 时统一用 encodingutf-8-sig这是带 BOM 头的 UTF-8Windows 的 Excel 认它Linux 工具链也不受影响。你的下游同事拿到的文件不管是 Excel 还是 Python 打开中文都正常。4.3 用户 ID 一边是 int 一边是 strmerge 结果全空现象用户基础信息表的 user_id 是数值类型比如 1001而订单表里的 user_id 是字符串类型比如字符串 1001。两张表 merge 完之后RFM 字段全是 NaN排查半天发现连接键类型不一致。原因上游不同系统导出的用户 ID 格式不统一有的系统把 ID 当数值存有的当字符串存。pandas 在做 merge 时对类型不一致的连接键不自动转换直接导致匹配不上。解决合并之前统一转成字符串类型这是最稳的做法同时要检查有没有空格之类的隐藏字符# 统一ID类型顺便去掉两端空白和无法打印的字符 df_user[user_id] df_user[user_id].astype(str).str.strip() df_order[user_id] df_order[user_id].astype(str).str.strip() # 合并前先看看ID交集有多少提前发现数据范围不匹配 valid_ids set(df_user[user_id]) set(df_order[user_id]) print(f用户在订单中的覆盖率: {len(valid_ids) / df_user[user_id].nunique():.2%})这个覆盖率的打印很有价值。如果覆盖率只有 60%说明有 40% 的用户注册了但从未下过单这是正常的如果覆盖率高达 99% 以上反而要怀疑订单数据是不是只包含部分渠道。这个指标能帮你提前识别数据质量问题而不是等画像做出来再被业务方质疑。4.4 时间字段是字符串recency 算成了负数现象计算 RFM 时recency 列出现负值或者算出几千天这种离谱的数字用户的“活跃度”直接失真。原因order_date 列在原始数据里是字符串格式比如 2024/01/15 或 20240115pandas 不知道它是日期直接用减法得到的是字符串拼接结果或错误的时间差。还有一种情况是日期格式混用一部分是 2024-01-15另一部分是 2024/01/15pd.to_datetime 解析时自动推断推断失败就变成 NaT。解决读入数据后立刻统一转 datetime并指定格式参数避免推断# 统一日期格式errorscoerce让无法解析的值变成NaT而不是报错 df_order[order_date] pd.to_datetime( df_order[order_date], format%Y-%m-%d, errorscoerce ) # 检查解析失败的比例超过阈值要回头找上游确认数据 parse_fail_rate df_order[order_date].isna().mean() assert parse_fail_rate 0.01, f日期解析失败率过高: {parse_fail_rate:.2%}这里用 assert 做质量闸门如果解析失败率超过 1%直接停止后续流程避免带着脏数据往下算。等上游修复数据之后再重新跑这一节。日期格式一旦统一后面无论是算 R 间隔还是做时间窗口筛选都不会再出事。4.5 pd.qcut 报错Bin edges must be unique现象跑 pd.qcut(df_rfm[frequency], 4, labels[...]) 的时候报 ValueError: Bin edges must be unique。原因这是 qcut 最经典的报错之一。qcut 按分位数切分数据如果某个分位点上的值大量重复比如 60% 的用户购买次数都是 1 次那 1、2、3 分位点的值可能都是 1箱子边界无法唯一确定算法直接报错。解决加 duplicatesdrop 参数让 qcut 在分位点重复时自动丢弃重复边界减少箱子数量如果业务上需要固定的四档改用自定义阈值分箱# 方案一允许qcut自动合并重复分位点 df_rfm[F_level] pd.qcut( df_rfm[frequency], 4, labels[低频, 中频, 高频, 高频], duplicatesdrop ) # 方案二根据业务认知自定义分箱边界 bins_freq [0, 1, 3, 6, df_rfm[frequency].max()] df_rfm[F_level] pd.cut( df_rfm[frequency], binsbins_freq, labels[1次, 2-3次, 4-6次, 7次以上], rightTrue )两条路线的选择取决于业务目标。等频分箱适合做人群分层每一层人数大致相等对比分析时不会出现某组样本过少自定义阈值适合和业务方已有认知对齐比如运营定义“高频用户月购 4 次以上”你就按这个来。我的习惯是优先按业务口径切业务没口径时再用分位数并且把分箱边界打印出来附在最终报告里任何人对标签口径有疑问时可以直接溯源。5. 把 Notebook 变成可复用模板参数化、结果验证与交接当你跑通了第一版用户画像后面还有无尽的迭代等着你数据更新了、业务要加标签、上游字段改了。如果每次都在 Notebook 里手动改数字改漏一个地方结果就静默出错。所以最后一步是把 Notebook 改造成参数化模板让我不用每次从头想。我一般会在 Notebook 的第一个 cell 集中定义一组配置项所有后续单元格都引用它# config cell集中管理所有可调参数 CONFIG { data_path: data/raw/订单数据.csv, user_path: data/raw/用户基础信息.csv, obs_date: None, # None表示取订单最大日期1天 rfm_bins: 4, # RFM分箱数 min_parse_rate: 0.99, # 日期解析成功率阈值 min_cover_rate: 0.80, # 用户覆盖率阈值 output_path: output/用户画像宽表.csv }这样做的好处是参数集中放在一起改动时有据可查。数据更新后把 data_path 指向新文件跑一遍整个 Notebook所有中间结果和图表自动刷新。obs_date 设置成 None 时按历史数据自动推算观察日设置成具体日期时用于回测某段历史时点的画像结果这个回测能力在验证用户分层时非常有用。验证画像结果对不对我习惯用两个手段。第一是抽样手算从 df_profile 中随机抽三个用户人工从订单明细里核算 R、F、M 三个值跟代码输出比对。手算能发现很多隐蔽问题比如某个用户有一笔退款单被计入金额或者时区差别导致 R 值差了一天。第二是分布合理性检查一个成熟的画像体系用户等级分布不会出现极端倾斜比如“高客单”用户占 60%那就说明分箱策略有问题切分点和业务感知对不上。另一个我常用的验证手段是交叉验证标签间的关系是否符合常识。比如“沉睡”用户的比例应该随着 M 金额等级降低而升高如果“高客单”用户里冒出一大批“沉睡”大概率是数据和口径有问题要回到前面的聚合逻辑排查。把这个验证步骤写成一个独立 cell每次跑完画像自动输出这些检查指标比肉眼盯表格靠谱得多。最后是交接问题。Notebook 里有大量探索性代码直接扔给业务方没有意义。我会把最终口径整理成一份简短的字段说明和画像宽表放在同一目录下再把 Notebook 导出为 HTML 存档# 在命令行执行把Notebook导出为HTML方便非技术同事阅读 !jupyter nbconvert --to html notebooks/用户画像构建.ipynb --output-diroutput/这份 HTML 保留了全部代码和输出业务方不用装 Python 也能看到整个计算过程。代码注释里写清楚每一步的日期口径和分箱依据。我第一次做用户画像时没有做参数化和验证一个月后数据更新重跑发现一个月前交付的 M 值用的是含退款的总金额口径跟新版本不一致业务方拿着新旧两张表对不上账那次教训让我深刻意识到用户画像项目的核心不只是“算出来”而是“可复现、可解释、可维护”。这个习惯一直保留到现在。希望你少踩一次这个坑希望帮到你。本文还有配套的精品资源点击获取
