简介一套基于Python技术栈的RFM用户画像可视化系统代码包面向数据分析、电商运营和Django Web开发学习者。项目使用Pandas、Scikit-learn完成RFM指标计算与K-means用户分群借助Django框架与Matplotlib、Seaborn构建可视化后台覆盖数据清洗、特征构建、聚类分析和图表展示全流程。压缩包共43个文件、约14.72MB包括py源码、html模板、csv与tsv数据表、ipynb建模笔记、sqlite数据库等目录按Django项目、数据模块组织便于快速定位。已有1565人学习下载。资源内置完整目录结构与可运行配置附带环境安装说明和启动操作文档适合对照实操深入理解RFM模型在用户分层、精准营销和业务决策中的应用。1. 基于RFM的用户画像可视化系统拆包之前先搞懂这套代码要解决什么问题看到「RFM用户画像可视化系统」这个名字有数据分析经验的朋友应该不陌生R是最近一次消费距今多久F是一段时间内消费多少次M是一共花了多少钱。这套代码分享真正值钱的地方不是RFM三个字母而是它把「从一张宽表到聚类分群再到浏览器里能点的页面」整条链路打通了。压缩包里既有Django工程app210406又有data目录下的电信用户画像.csv和多张tsv明细表还有一个电信用户画像.ipynb建模脚本环境安装docx和启动说明也齐了。适合两类人一类是把RFM停留在Excel透视表阶段、想看看Python和Django怎么串起来的新手另一类是有Web基础、想找一份能改的完整工程做二次开发的从业者。这篇文章就按我拆包的顺序把这套系统的数据口径、聚类参数、页面渲染和几个最容易翻车的点讲清楚。2. RFM三指标怎么算Recency的窗口期与F/M的统计口径2.1 从data目录的数据文件说起先分清事实表和维度表解压之后先别急着跑代码data目录下那一堆tsv文件是有讲究的。dim_app_flag.tsv和dim_call_flag.tsv是维度表说的是某个app标识、某个通话类型对应什么业务含义。ods_user_app_detail_dt_201612.tsv这一批带ods前缀的是明细事实表每一行是一条用户行为记录。电信用户画像.csv和最终RFM数据归一表.csv则是已经加工过的中间结果。我一般会先写一小段代码确认字段名再决定从哪张表开始算RFM。特别是tsv文件列名经常带着隐藏的换行符或制表符直接读进来后列名是乱的这一步能省掉后面一个小时的排查时间。import pandas as pd # 先只读前5行确认字段名清洗干净再生产 df_raw pd.read_csv(data/ods_user_call_detail_dt_201612.tsv, sep\t, nrows5) print(df_raw.columns.tolist()) print(df_raw.head()) # 如果列名带着 \n 或左右空格用这一行清洗 df_raw.columns [c.strip().replace(\n, ) for c in df_raw.columns]逻辑说明read_csv默认分隔符是逗号tsv文件必须显式传sep\t。nrows5是为了先看结构避免字段有问题时全量读取浪费时间。列名清洗那一行为什么重要因为后面groupby、merge全部依赖列名列名里藏一个换行符代码不报错但结果列就是取不到数这类bug非常隐蔽。参数说明sep指定制表符分隔nrows控制读取行数调试时建议保持小值确认无误后去掉。如果csv文件是电信用户画像.csv这类中间结果一般编码是UTF-8遇到中文乱码就改成encodingutf-8-sigExcel导出的csv经常需要这个参数。2.2 Recency的计算口径不是简单减一下日期就行R值表示「用户最近一次互动距今多久」最容易犯的错是直接把当前日期减去数据里的交易日期。实际上要分两步第一步找每个用户最近一次互动时间第二步用统一的统计截止日减去这个时间。数据明细表里可能有通话、短信、App访问三类行为如果做全量RFM这三类行为要各自算最近时间再取最大还是只算某一种业务行为必须在动手前定清楚。import datetime # 假设df是明细数据至少包含 user_id 和 biz_date 两列 df[biz_date] pd.to_datetime(df[biz_date]) # 每个用户最近一次互动时间 recency_df df.groupby(user_id)[biz_date].max().reset_index() recency_df.columns [user_id, last_date] # 使用统一的统计截止日不要用 data.max() 替代 cutoff_date datetime.date(2016, 12, 31) recency_df[recency_days] (pd.Timestamp(cutoff_date) - recency_df[last_date]).dt.days逻辑说明groupby后取max得到的是每个用户最后一次互动的时间点。cutoff_date这里用2016年12月31日是因为数据文件命名里带dt_201612业务周期是2016年12月这是最常见的口径。如果直接用df[biz_date].max()作为截止日那最后一个活跃用户的R值会变成0所有用户新建了最近时间的偏移聚类时会无形加大样本差异。参数说明cutoff_date用datetime.date而不是字符串是为了和pd.Timestamp做减法时类型一致。dt.days取天数差如果数据里last_date未来日期存在这里会出现负数后面避坑章专门讲。2.3 F和M的聚合口径groupby聚合与去重陷阱F值是消费频率M值是消费金额。听起来简单实际有两个坑。第一如果一张订单对应多行商品明细直接对明细行数计数会把一次消费算成多次第二M值应该对订单金额求和还是对行金额求和取决于数据表里金额字段是「订单总金额重复在每一行」还是「每行商品金额」。# 构造订单级数据每个订单一行 order_df df.groupby([user_id, order_id], as_indexFalse).agg( order_amount(amount, sum), order_cnt(amount, count) # 行数用于检查是否有重复 ) # 用户维度聚合 rfm_df order_df.groupby(user_id, as_indexFalse).agg( frequency(order_id, nunique), # 去重后的订单数 monetary(order_amount, sum) # 用户总消费金额 ) # 把R值和F/M合并到一张表 rfm_full recency_df.merge(rfm_df, onuser_id, howleft) rfm_full[frequency] rfm_full[frequency].fillna(0) rfm_full[monetary] rfm_full[monetary].fillna(0)逻辑说明先按user_id和order_id分组聚合把订单明细压成订单级再在用户级对order_id取nunique这样即使用户有多行明细频次依然只算一次。monetary字段sum的语义取决于源表如果源表订单金额是单行总计那第一步的sum就多余了建议先打印order_amount的分布确认一下。参数说明as_indexFalse让分组字段保留为普通列方便后续merge。howleft保证那些有最近互动但没有消费记录的用户也保留在RFM表里缺失的F和M填0。这是RFM建模里很容易被忽略的一步很多人直接inner join把沉默用户全丢了后面聚类分群结果必然偏乐观。fillna(0)在这里尤其重要没有消费行为不代表用户不存在R值还是有效的只是F和M为零这类用户恰恰是流失预警的核心对象。3. 从RFM分数到用户分群K-means聚类与阈值法的取舍3.1 两类分群方式怎么选业务阈值和聚类各有适用场景RFM算完接下来要把用户分成不同价值等级。常见做法有两种一是给R/F/M分别设阈值打分比如R小于30天打5分30到60天打4分再用分数组合出「重要价值用户」「一般价值用户」这类标签这是传统RFM模型的标准玩法二是把R/F/M三列丢给K-means聚类让算法自己找群体边界。阈值法的优点是完全可解释业务方听得懂缺点是阈值得拍脑袋不同行业差异巨大。电商30天算高频装修建材30天才刚完成一次咨询同一套阈值换个业务就是灾难。聚类法的优点是边界自动适应数据分布R值普遍偏小时分群依然合理代价是聚类结果需要人工对照业务含义命名。这个项目选了K-means我在实操中也更倾向于让聚类先跑一遍再看聚类中心的业务含义必要时再回退到阈值法。维度阈值打分法K-means聚类法可解释性高每个分值有明确业务含义中需要结合聚类中心解释跨业务复用差换业务要重新调阈值好重新聚类即可数据要求三列分布均匀边界清晰能适应偏态分布但需要标准化分群数量固定5×525格通常合并成8类由肘部法则/轮廓系数决定3.2 聚类实操标准化、K值选择与分群命名K-means对特征尺度敏感M值动辄上千R值只有几十不标准化的话M值会完全主导聚类结果。先把三项特征做Z-score标准化再跑聚类用轮廓系数辅助选K值。from sklearn.preprocessing import StandardScaler from sklearn.cluster import KMeans from sklearn.metrics import silhouette_score import pandas as pd import numpy as np # rfm_full 是上一步计算的结果至少要包含 recency_days, frequency, monetary features [recency_days, frequency, monetary] X rfm_full[features].fillna(0).values # Z-score 标准化 scaler StandardScaler() X_scaled scaler.fit_transform(X) # 尝试 2~8 个簇输出轮廓系数 for k in range(2, 9): km KMeans(n_clustersk, random_state42, n_init10, max_iter300) labels km.fit_predict(X_scaled) score silhouette_score(X_scaled, labels) print(fk{k}, silhouette{score:.4f}) # 选定 k假设选4重新训练并给标签 k 4 km KMeans(n_clustersk, random_state42, n_init10, max_iter300) rfm_full[cluster] km.fit_predict(X_scaled) # 看每个簇的中心反标准化回原始尺度 center_df pd.DataFrame( scaler.inverse_transform(km.cluster_centers_), columnsfeatures ) print(center_df.sort_values(monetary, ascendingFalse))逻辑说明Z-score标准化的含义就是把每一列变成均值0、标准差1M值再大也不会在欧氏距离里霸榜。轮廓系数取-1到1越大说明簇内紧凑、簇间分离越好但这只是个参考K4可能轮廓系数不是最高但业务上最清晰实际使用先看轮廓系数选候选区间再结合业务可解释性定K。random_state固定42是为了复现结果n_init10是让算法跑10轮取最优避免随机初始质心带来的抖动。参数说明max_iter是单轮迭代上限一般300足够。fillna(0)要放在聚类之前做否则sklearn直接报错或者把NaN当缺失值处理。center_df反标准化这一步很重要因为聚类中心是标准化的数值直接看没意义inverse_transform还原后再按monetary排序才能读出「哪个簇是高价值用户」。聚类完成后每个簇只是个编号0到3需要人工映射业务标签。常见做法是观察聚类中心的R/F/M方向R小、F大、M大显然就是高价值忠实用户R大、F小、M小是流失沉默用户R小、F大、M中等可能是高频低消用户。映射关系写成一个字典直接放到DataFrame里。# 按聚类中心含义映射业务标签 cluster_names { 0: 高价值活跃用户, 1: 普通用户, 2: 低频高潜用户, 3: 流失风险用户 } rfm_full[user_tag] rfm_full[cluster].map(cluster_names)逻辑说明这里的映射顺序只是示例实际每个簇的编号由K-means随机初始质心决定必须在cluster_centers_出来后按业务含义手动对号入座。一个血泪经验是换random_state后簇编号顺序可能变如果代码里硬编码标签结果会全部错位所以映射字典应该写在center_df输出之后不能反过来。3.3 把分群结果导出给DjangoCSV还是SQLite分析在Jupyter里做完了接下来要让Django页面能读到结果。这里两条路一是导出成csvDjango视图直接读文件二是把rfm_full写进db.sqlite3用Django ORM查。项目压缩包里db.sqlite3已经存在说明作者倾向走数据库路线。我自己的习惯是中间结果导出CSV做存档同时再写一份到SQLite供Web查询CSV那份留给以后回溯口径用。# 导出固定列序的CSV供Django视图读取 export_cols [ user_id, recency_days, frequency, monetary, cluster, user_tag ] rfm_full[export_cols].to_csv( data/app数据rfm数据归一表.csv, indexFalse, encodingutf-8-sig )逻辑说明to_csv导出时indexFalse非常关键否则会把行号当一列写进文件Django那边读出来莫名多一列。encodingutf-8-sig是为了兼容Excel和Django模板同时读取的场景加了BOM头Windows下打开csv中文不乱码。参数说明export_cols控制列顺序视图层和模板层都依赖这个顺序改了列名要三处同步改。如果选择写入sqlite3用df.to_sql(rfm_result, con, if_existsreplace, indexFalse)需要依赖sqlalchemy库如果环境里没装pip install sqlalchemy即可这里不过度展开。4. Django渲染用户画像页面视图、模板与图表的接法4.1 视图层只做取数和传值Django Views的职责划分Django项目里views.py负责接收HTTP请求、查数、把数据塞给模板。RFM系统的视图不需要写复杂业务逻辑分群在Notebook里已经做完了视图层就是把结果读出来、按模板需要整理成结构体。我倾向于把「读取文件」和「组装上下文」分开方便后续调整。# app210406/views.py 典型实现 import pandas as pd from django.shortcuts import render def user_class_view(request): # 读取聚类结果 df pd.read_csv( data/app数据rfm数据归一表.csv, encodingutf-8-sig ) # 统计每个分群的人数与占比 group_stats ( df.groupby(user_tag) .size() .reset_index(nameuser_cnt) ) group_stats[ratio] ( group_stats[user_cnt] / df.shape[0] * 100 ).round(2) context { group_stats: group_stats.to_dict(records), } return render(request, user_class.html, context)逻辑说明group_stats用groupby统计每个user_tag落入的人数ratio是占比。to_dict(records)把DataFrame转成列表字典Django模板可以直接用for循环遍历。读取路径写的是相对路径实际部署时要改成绝对路径或放到static/media目录否则Django的工作目录一变就读取失败。参数说明reset_index(nameuser_cnt)把groupby结果的索引变成普通列并指定列名为user_cnt不写name参数会默认叫0模板里就要写item.0可读性差。ratio保留两位小数模板里显示百分比时直接拼一个%号即可如果要在模板里做乘除运算建议后端算完再传。4.2 模板文件怎么分工base模板和error页的意义压缩包里的templates目录包含base.html、start.html、user_class.html、error.html、user_base.html几个文件。Django模板的基本逻辑是base.html定义整体骨架子页面继承后填充内容块。error.html的存在说明作者考虑了数据读取异常的场景比如文件不存在可给一个友好提示页面而不是白屏500。模板文件作用base.html全局布局、导航栏、静态文件引用user_base.html用户画像模块的基础布局可继承basestart.html系统入口/说明页展示分析概览user_class.htmlRFM分群结果页核心数据展示error.html数据缺失时兜底提示页user_class.html里最常见的结构是顶部放四个卡片分别展示总用户数、高价值用户数、平均R值、平均M值中间放分群占比表格底部放RFM三指标分布图。这个布局对运营人员很友好一眼能看到最重要的结论。4.3 把Matplotlib图表塞进页面静态文件与Base64编码Django项目里展示图表有两种做法一种是Matplotlib生成png存到static目录模板里用img标签引用另一种是图表在视图函数中直接生成通过base64编码转成字符串放进context。第二种更适合临时性的分析系统不需要清理历史文件每刷新一次重新生成图表。import base64 from io import BytesIO import matplotlib.pyplot as plt import pandas as pd plt.rcParams[font.sans-serif] [SimHei, Microsoft YaHei] plt.rcParams[axes.unicode_minus] False def build_rfm_chart(df): fig, axes plt.subplots(1, 3, figsize(12, 3.5)) features [recency_days, frequency, monetary] for ax, col in zip(axes, features): ax.hist(df[col], bins30, color#4C72B0, alpha0.7) ax.set_title(col) ax.grid(alpha0.3) plt.tight_layout() buf BytesIO() plt.savefig(buf, formatpng, dpi100) buf.seek(0) img_base64 base64.b64encode(buf.read()).decode(utf-8) plt.close(fig) return img_base64逻辑说明这段代码生成三张直方图分别看R/F/M的分布。关键在savefig时使用BytesIO而不是直接写文件这避免在服务器磁盘上留下临时图片。保存完成后要调用plt.close(fig)释放内存否则每个请求都开一个新Figure对象内存会持续上涨。参数说明figsize控制图片宽高比12比3.5适合横跨页面的布局。dpi100是清晰度和体积的平衡点内部分析用100够用要打印才调150。bins30对几万用户量级是合理选择用户量少时可以降到20。中文显示的坑是plt.rcParams那两句不在代码开头设置的话图里所有中文都会显示成方框这一步在Windows和Linux下都容易踩。视图函数拿到这个base64字符串后放进context传给模板模板里直接写img srcdata:image/png;base64,{{ chart_str }}就行。注意模板用的是Django模板语言三引号字符串直接赋值即可。5. RFM可视化避坑实操从数据口径到页面渲染的常见问题5.1 pandas读tsv列名带换行符导致聚类结果全错现象读入ods_user_call_detail_dt_201612.tsv后df.columns打印出来有乱码列名里似乎有\n直接用df[calling_dur]取数报KeyError或者不报错但值是NaN。原因tsv文件里的字段名末尾带有换行符或制表符read_csv默认识别的列名是原始字符串清洗不到位的话后续groupby、merge全部静默失败更坑的是某些情况下列名恰好包含隐藏字符时数据也能读出来只是结果少了这一列。解决读入后立刻执行df.columns [c.strip().replace(\n, ) for c in df.columns]strip去掉两端空格和制表符replace把换行符替换为空。这个小动作我建议做成固定习惯凡是外部给的csv或tsv一律先洗列名再干活。5.2 Recency算出负数聚类出现一个全是异常值的簇现象recency_days列有负数比如-3-15聚类时这些负值形成一个单独的簇轮廓系数看起来还不错但业务上完全说不通。原因数据里某些biz_date晚于cutoff_date通常是上游同步数据时把未来时间也写进来了。直接用pd.Timestamp(cutoff_date) - df[biz_date]得到的days就是负值。解决清洗阶段加一道过滤df df[df[biz_date] pd.Timestamp(cutoff_date)]把未来日期的记录剔除或者用clip_lower0强制归零。我更推荐前者因为clip会让异常数据混进正常样本聚类结果被污染只是表面看不出来。5.3 K-means分群编号和业务标签对不上高价值群人数大得离谱现象聚类完成后看user_tag分布发现「高价值活跃用户」占了总人数的60%明显不合常理但聚类中心数据看起来没问题。原因常见情况是聚类的簇编号不是按价值从高到低排序的K-means的cluster_centers_输出顺序和标签映射字典的key没有对应代码里硬编码了cluster0对应高价值但实际编号0的簇是低价值用户。也可能数据里M值极度偏态几个大额用户主导了一个簇。解决不要用编号猜业务含义先打印cluster_centers_反标准化后的数值按monetary排序后逐一对照。还有一招是画箱线图把每个簇的F和M分布拎出来看确认这个簇是否真的符合「高价值」的业务直觉。我当时还犯过一个低级错误——映射字典写对了但map的时候用了字符串列cluster而不是整数列导致所有标签都是NaN模板上只显示空白。5.4 Django页面读取csv中文乱码图表里中文字体变成方框现象页面上的用户名和标签全部乱码Matplotlib生成的图表标题全是□□□。原因csv读取没用utf-8-sig编码Windows下Excel导出的csv默认是GBKDjango视图读进来就是乱码Matplotlib默认字体是DejaVu Sans不支持中文。解决csv读取统一用encodingutf-8-sig能兼容UTF-8带BOM和纯UTF-8文件。Matplotlib字体设置放在导入后第一行执行plt.rcParams[font.sans-serif] [SimHei, Microsoft YaHei]注意Linux服务器上没有SimHei字体需要先安装中文字体包或者用思源黑体否则设置不生效。5.5 图表不显示或图片路径404现象本地开发图表能显示部署到服务器就显示一个broken image小图标查看网络请求发现原始地址是/static/rfm_chart.png返回404。原因Django的STATIC_URL和STATICFILES_DIRS配置不一致或者模板里写死了绝对路径。debug模式Django能自己找static关掉debug后就要求collectstatic把静态文件收集到一个目录否则图片路径全部失效。解决settings.py里配置STATICFILES_DIRS指向项目根目录的static文件夹模板里用{% load static %}和{% static xxx.png %}生成路径不要手写路径字符串。如果是Base64内嵌图表的方式就不存在这个问题这也是我在分群结果页面更推荐Base64的另一个原因——少踩一个静态文件的坑。6. 给系统装上可调的RFM参数不改代码就换分群结果RFM系统做到能跑只是第一步真正让运营同事愿意天天用的是「参数可调」。我最初版本写死了r_days90、k4业务方说要改成看60天的消费活跃度我得重新跑一遍Notebook再重启Django一来一回半小时就没了。后来我把参数抽到config.py在视图层加了一个「从URL参数覆盖默认值」的小机制一下解决问题。# project/config/rfm_config.py 示例 RFM_CONFIG { r_days: 90, # Recency统计窗口 k: 4, # 聚类簇数 random_state: 42, # 随机种子 fill_zero: True, # F/M 缺失是否填0 } # views.py 中读取请求参数覆盖默认值 import copy def get_rfm_config(request): config copy.deepcopy(RFM_CONFIG) if request.GET.get(r_days): config[r_days] int(request.GET[r_days]) if request.GET.get(k): config[k] int(request.GET[k]) return config逻辑说明deepcopy很关键如果不复制而直接改全局字典一次请求的参数会被带到下一次时间一长配置就乱了。视图层拿到config后把r_daysfalse传给RFM重算函数聚类得出新标签再走一遍第4章的渲染流程页面就能动态变化了。实际效果就是模板里放一个下拉框选中「60天」「90天」或者输入K值后刷新页面图表和分群占比立刻更新。参数说明r_days控制参与计算的最近互动窗口小于这个阈值的记录才纳入R值计算。k控制聚类簇数一般设2到8之间设太大每一簇人数太少没业务意义。random_state固定后参数相同的请求结果完全可复现排查问题时能确定是数据变了而不是随机性导致。这个功能做完以后我在本地把r_days分别设成30、60、90跑了一遍对比三个人数占比确认结果差异符合业务判断才敢交付。从那以后我每次做RFM类似项目都把「统计口径」和「算法实现」强制拆成两个独立模块前端参数进视图、视图进配置、配置进计算改一处即可生效。这套做法也沿用到了其他分析型Django项目里希望帮到你。本文还有配套的精品资源点击获取