1. 项目概述用PandasPlotly做数据可视化不是画图是让数据开口说话“Data Visualisation using Pandas and Plotly”这个标题看起来平平无奇像极了某门网课的章节名——但如果你真把它当成“学几个plot函数就完事”的入门练习那接下来三个月你大概率会反复打开Jupyter Notebook对着空荡荡的图表发呆数据加载成功了折线也画出来了可老板问“上个月销量下滑到底卡在哪个环节”你只能指着Y轴说“这里低了”。这不是技术问题是思维断层。我带过27个数据分析岗新人90%卡在同一个地方把可视化当成绘图工具而不是数据推理的延伸界面。Pandas负责把原始数据拧成逻辑清晰的结构化事实Plotly则把这种结构翻译成人眼能瞬间捕捉的视觉语法——比如用颜色深浅表达区域渗透率差异用悬停交互暴露单点异常值用动画帧展示时间维度上的拐点迁移。它不追求“好看”而追求“一瞥即懂”。适合谁刚转行的数据分析初学者、需要快速产出业务看板的产品经理、被Excel图表折磨到失眠的运营同学甚至想给论文加点动态效果的研究生。关键不在代码多炫而在你能否在3秒内让同事指着屏幕说“哦原来问题出在这里”。这背后藏着三重能力Pandas的数据整形直觉不是查文档拼df.groupby而是预判聚合后该保留哪些索引层级、Plotly的视觉映射意识知道什么时候该用散点图的size参数编码第三维而不是硬塞进颜色以及最关键的——对业务场景的具象化理解比如“用户流失预警”不是画个折线图而是把留存率、次日启动间隔、功能使用深度三个指标压缩进一个可缩放的气泡矩阵。下面我们就从真实项目现场拆解怎么让这两套工具真正咬合而不是各自为政。2. 核心设计思路为什么非得是PandasPlotly组合绕开Matplotlib的思维陷阱2.1 选型背后的底层逻辑从“静态快照”到“交互式推理界面”很多人问“Matplotlib不是更基础吗学它不更稳妥”——这话放在2015年没错但今天再这么想等于用算盘思维操作Excel。Matplotlib的本质是绘图引擎它的API设计哲学是“我提供画布和笔你决定每条线怎么落笔”。所以你会看到大量plt.subplot()、ax.set_xlabel()这类命令式操作写10行代码可能只为了调一个坐标轴字体大小。而Plotly的设计原点完全不同它是数据驱动的可视化框架。你告诉它“这是我的数据表X轴是日期Y轴是销售额颜色按地区分”它自动推导出最优图表类型、坐标轴范围、交互行为比如默认开启悬停提示。这种差异直接决定了工作流效率用Matplotlib画一个带筛选器的销售看板你要手动写回调函数、绑定事件、重绘画布用Plotly一行px.line(df, xdate, ysales, colorregion, hover_data[product])就能生成带悬停、缩放、下载按钮的完整交互图。但这还不是全部。真正的杀招在于Pandas与Plotly的数据契约天然契合。Pandas的DataFrame本身就是二维表格结构而Plotly所有高级图表px.scatter,px.bar,px.treemap的输入参数都明确要求data_frame参数且内部直接解析DataFrame的列名、数据类型、缺失值状态。我试过把同一份销售数据分别喂给Matplotlib和PlotlyMatplotlib需要先用df.groupby(region)[sales].sum().plot(kindbar)再手动设置xticks旋转角度防止标签重叠Plotly只需px.bar(df, xregion, ysales)它自动处理分类轴排序、长文本换行、数值格式化。这种“少写代码少出错”的优势在需要快速迭代业务需求时就是生死线。2.2 绕不开的Pandas预处理可视化质量80%取决于数据整形新手常犯的致命错误是跳过Pandas直接冲向Plotly。结果就是图表画出来了但老板第一句就问“这个‘其他’类别里到底包含什么”——因为原始数据里有200多个小众品牌Pandas没做归并Plotly只好把它们全塞进图例。可视化不是数据的终点而是数据清洗的验收测试。真正高效的流程永远是Pandas整形 → Plotly渲染 → 业务验证 → Pandas修正 → Plotly重绘。举个血泪案例去年帮电商团队做复购率分析原始订单表有千万级记录包含user_id,order_date,product_category,amount。如果直接px.line(df, xorder_date, yamount)得到的是一团无法解读的毛线图。必须先用Pandas完成三步手术时间切片df[order_date] pd.to_datetime(df[order_date])否则Plotly会把日期当字符串排序聚合降维df_daily df.groupby(df[order_date].dt.date)[amount].sum().reset_index()把千万行压成365行业务标注df_daily[is_promotion] df_daily[order_date].apply(lambda x: 1 if x in promotion_dates else 0)为后续添加促销标记埋点。这三步做完Plotly才能发挥威力px.line(df_daily, xorder_date, yamount, coloris_promotion, markersTrue)。注意markersTrue这个参数——它让每个数据点变成可点击的圆点悬停时显示具体日期和金额这才是业务人员真正需要的“钻取能力”。绕过Pandas预处理等于让Plotly在沼泽地里开车再好的引擎也跑不快。2.3 Plotly的不可替代性交互不是锦上添花而是核心生产力有人觉得“交互功能用不上公司内网不让联网”。这种认知停留在2018年。Plotly的交互能力早已超越“鼠标悬停”这种表层功能深入到分析范式重构层面。最典型的例子是px.treemap树状图。假设你要分析APP功能使用深度一级是模块首页、购物车、个人中心二级是子功能首页→搜索框、首页→轮播图、购物车→结算按钮。用传统饼图你最多看到各模块占比用树状图面积大小代表使用时长颜色深浅代表跳出率点击任意区块能自动下钻到子功能层。这个能力背后是Plotly对Pandas层级索引MultiIndex的原生支持。我实测过把df.set_index([module, sub_function])后的数据传给px.treemap它自动识别索引层级并生成可交互树状结构。而Matplotlib要实现同样效果得手动计算每个区块坐标、写递归绘制函数、绑定点击事件——这已经不是可视化是开发小型应用了。另一个常被低估的能力是动画时间轴。比如分析用户生命周期价值LTV你需要看不同注册月份的用户其30天/60天/90天留存率如何变化。用px.line(df, xdays_since_register, yretention_rate, animation_framecohort_month)Plotly自动生成带播放控件的时间序列动画。业务方拖动滑块就能直观看到2023年Q4新用户在第45天出现明显留存拐点而Q1用户在第30天就断崖下跌。这种动态洞察静态图表永远做不到。所以别再说“交互用不上”当你需要向非技术人员解释复杂趋势时那个能暂停、回放、放大细节的动画就是最有力的说服工具。3. 核心细节解析Pandas数据整形的5个关键动作与Plotly参数精解3.1 Pandas数据整形5个决定图表成败的预处理动作3.1.1 时间字段标准化别让日期成为图表的隐形杀手时间类数据是可视化事故高发区。我见过最离谱的案例销售报表里order_time列存储的是字符串2023-01-01 14:30:22直接传给Plotly后X轴显示为乱码字符。正确姿势分三步走第一步强制类型转换# 错误示范df[order_time] df[order_time].astype(datetime64[ns]) # 正确做法用pd.to_datetime()并捕获异常 df[order_time] pd.to_datetime(df[order_time], errorscoerce) # errorscoerce会把无法解析的值转为NaTNot a Time避免报错中断第二步提取业务时间粒度# 按日聚合df[order_date] df[order_time].dt.date # 按周聚合周一为起点df[order_week] df[order_time].dt.to_period(W-MON) # 按月聚合df[order_month] df[order_time].dt.to_period(M) # 注意to_period()返回Period类型Plotly能自动识别并按时间顺序排序第三步处理时区与缺失值# 如果数据含时区如2023-01-01 14:30:2208:00统一转为UTC再本地化 df[order_time_utc] pd.to_datetime(df[order_time]).dt.tz_localize(UTC) df[order_time_beijing] df[order_time_utc].dt.tz_convert(Asia/Shanghai) # 缺失值处理Plotly会自动忽略NaT但需确认是否影响业务逻辑 print(f时间字段缺失率{df[order_time].isna().mean():.2%}) # 若缺失率5%需业务方确认是否补零或前向填充提示永远在Pandas中完成时间标准化不要依赖Plotly的xaxis_typedate参数。后者仅影响显示格式无法修复类型错误导致的排序混乱。3.1.2 分类字段优化让图例不再成为阅读障碍分类字段如产品名称、地区、用户等级常因取值过多导致图表崩溃。Plotly默认将所有唯一值加入图例当product_name有500个值时图例会撑满整个屏幕。解决方案是Pandas端的主动治理# 方案1高频词TOP-N 其他合并 top_products df[product_name].value_counts().head(10).index df[product_group] df[product_name].apply(lambda x: x if x in top_products else 其他) # 方案2按业务逻辑分组推荐 product_mapping { iPhone 14 Pro: 高端旗舰, iPhone 13: 主力机型, iPad Air: 平板系列, MacBook Pro: 笔记本系列 } df[product_category] df[product_name].map(product_mapping).fillna(其他) # 方案3数值型分箱如价格区间 df[price_range] pd.cut(df[price], bins[0, 3000, 6000, 10000], labels[低端, 中端, 高端])关键点在于分组逻辑必须由业务方确认而非算法自动生成。我曾用KMeans对商品价格聚类结果把“99元充电宝”和“9999元MacBook”分到同一档被产品总监当场叫停。Pandas的map()和cut()函数就是你的业务翻译器把原始数据映射成可解释的业务语言。3.1.3 数值字段清洗异常值不是噪声是待解读的信号数值字段的异常值处理新手常犯两个极端要么全删丢失关键线索要么全留图表失真。正确策略是分层处理# 第一层识别明显错误如负数销量、超大金额 df df[(df[sales] 0) (df[sales] 100000)] # 假设单笔订单不超过10万 # 第二层业务定义的异常需与业务方对齐 # 例如单日销量均值3倍标准差标记为促销活动 mean_sales df[sales].mean() std_sales df[sales].std() df[is_promotion_day] (df[sales] mean_sales 3 * std_sales).astype(int) # 第三层保留原始数据用于下钻分析 # 创建新列存储清洗后数据原始列保留 df[sales_clean] df[sales].clip(lower0, upper100000) # clip()比布尔索引更安全不丢行Plotly的优势在于你可以同时可视化原始数据和清洗数据。比如用px.scatter(df, xdate, ysales, coloris_promotion_day)红色点自动标出异常日悬停查看具体数值这就是“异常值可视化”而非“删除异常值”。3.1.4 多表关联整合一张图背后是三张表的协同真实业务数据永远分散在多张表。比如分析用户留存需要用户表user_id, register_date行为表user_id, event_date, event_type订单表user_id, order_date, amount新手常试图用Plotly分别画三张图结果老板问“下单用户和活跃用户的重合度是多少”当场卡壳。正确解法是Pandas的merge()和pivot_table()# 步骤1构建用户生命周期宽表 user_life users.merge( behaviors.groupby(user_id)[event_date].min().rename(first_active_date), onuser_id, howleft ).merge( orders.groupby(user_id)[order_date].min().rename(first_order_date), onuser_id, howleft ) # 步骤2计算关键指标留存、转化等 user_life[day0_retention] (user_life[first_active_date] user_life[register_date]).astype(int) user_life[conversion_rate] (user_life[first_order_date].notna()).astype(int) # 步骤3透视成分析矩阵 cohort_analysis user_life.pivot_table( indexregister_month, columnscohort_day, # 如0,1,7,30天 valuesretention_rate, aggfuncmean ) # 此时cohort_analysis是DataFrame可直接喂给px.imshow()画热力图注意Plotly的px.imshow()专为矩阵数据设计比用px.scatter模拟热力图更高效且支持行列标签自动对齐。3.1.5 空值与重复值可视化前的最后防线空值和重复值看似简单却常引发连锁反应。比如px.line()遇到Y轴空值会自动断开线条导致趋势图出现诡异缺口重复的date值会让px.bar()柱子叠加变粗。处理原则是空值显式声明重复值业务判定。# 空值处理根据字段语义选择策略 df[revenue].fillna(0, inplaceTrue) # 收入为空0收入 df[category].fillna(未知, inplaceTrue) # 分类为空未知类别 # 但时间字段空值不能填应删除或标记 df df.dropna(subset[order_time]) # 重复值检查重点查复合主键 duplicate_cols [user_id, order_date, product_id] duplicates df.duplicated(subsetduplicate_cols, keepFalse) print(f重复订单数{duplicates.sum()}) # 业务决策是合并数量sum还是去重drop_duplicates # 通常订单表重复系统bug应报警而非自动处理记住Pandas的info()和describe()是你的可视化前哨站。每次画图前运行df.info()确保所有字段类型正确运行df.describe(includeall)一眼看出分类字段唯一值数量、数值字段分布范围——这比盯着Plotly报错信息高效十倍。3.2 Plotly参数精解10个改变图表命运的核心参数3.2.1hover_data让悬停成为你的第二张图表hover_data是Plotly最被低估的参数。它不只是显示额外信息而是扩展图表的信息维度。比如分析销售地域分布# 基础地图 fig px.choropleth(df, locationscountry_code, colorsales) # 加入hover_data悬停时显示多维信息 fig px.choropleth( df, locationscountry_code, colorsales, hover_data[country_name, sales, avg_order_value, new_users_count] )效果鼠标悬停美国区块弹窗显示“United States | $2.4M | $89.5 | 12,450”。这相当于在单点位置嵌入了一个微型数据透视表。更妙的是hover_data支持计算字段df[sales_per_user] df[sales] / df[user_count] fig px.choropleth(df, locationscountry_code, colorsales, hover_data[sales_per_user: :.2f]) # :.2f控制小数位实操心得永远把业务最关心的3个指标放进hover_data而不是堆砌所有字段。我测试过悬停信息超过5项时用户注意力会分散。优先级排序核心指标 对比基准 异常标识。3.2.2facet_col与facet_row用分面替代堆叠让多维对比一目了然当需要对比多个维度如不同产品线、不同地区、不同时间段时新手习惯用color参数堆叠结果图例爆炸。facet_col/facet_row提供更优雅的解法把多维对比转化为网格布局。# 错误用color区分产品线图例挤满屏幕 px.line(df, xdate, ysales, colorproduct_line) # 正确用facet_col分面每列一个产品线 px.line(df, xdate, ysales, facet_colproduct_line, facet_col_wrap3)facet_col_wrap3表示每行最多3列自动换行。效果是生成3×N的图表矩阵每个子图独立坐标轴避免量纲差异导致的视觉误导。更强大的是组合使用# 按产品线分列按地区分行 px.line(df, xdate, ysales, facet_colproduct_line, facet_rowregion)此时你得到的是一个二维分析矩阵业务方可以横向对比各产品线趋势纵向对比各地区表现。这比任何堆叠图都更符合人脑的并行处理习惯。3.2.3animation_frame时间维度不是X轴而是播放键animation_frame让时间序列分析从“静态快照”升级为“动态推演”。关键是要理解动画帧不是简单循环而是业务逻辑的可视化表达。# 基础动画按月份推进 px.scatter(df, xgdp_per_capita, ylife_expectancy, sizepopulation, animation_frameyear, animation_groupcountry) # 进阶用时间分组替代单一年份解决数据稀疏问题 df[year_group] df[year] // 5 * 5 # 每5年一组2000,2005,2010... px.scatter(df, xgdp_per_capita, ylife_expectancy, animation_frameyear_group, range_x[0, 100000])range_x固定坐标轴范围避免动画过程中坐标轴跳动干扰观察。实测发现当动画帧数20时建议用framedict(duration300)延长每帧停留时间否则业务方根本来不及读取信息。3.2.4log_x与log_y对数刻度不是炫技是揭示隐藏规律当数据跨度极大如用户消费从1元到10万元线性刻度会让小数值完全不可见。log_x/log_y参数就是你的放大镜# 线性刻度90%的点挤在左下角 px.scatter(df, xincome, yspending) # 对数刻度呈现完整分布形态 px.scatter(df, xincome, yspending, log_xTrue, log_yTrue)但要注意对数刻度要求数据严格0。因此Pandas预处理时必须过滤df df[(df[income] 0) (df[spending] 0)]更实用的技巧是结合marginal_x/marginal_y显示分布直方图px.scatter(df, xincome, yspending, log_xTrue, log_yTrue, marginal_xhistogram, marginal_ybox)这样右上角显示散点关系下方显示收入分布右侧显示支出分布一次调用完成三维分析。3.2.5trendline内置回归线不是替代建模而是快速假设检验px.scatter(..., trendlineols)能一键添加普通最小二乘回归线但它真正的价值在于快速验证业务直觉。比如你怀疑“广告投入越多销售额越高”画个散点图加趋势线fig px.scatter(df, xad_spend, ysales, trendlineols) # 获取斜率和R²量化关系强度 results px.get_trendline_results(fig) print(f斜率{results.iloc[0][px_fit_results].params[1]:.2f}) print(fR²{results.iloc[0][px_fit_results].rsquared:.3f})如果R²0.3说明线性关系很弱该转向其他分析路径如分段回归、引入交互项。这比先写几十行statsmodels代码再画图高效得多。但切记趋势线只是探索工具正式报告需用专业统计模型。3.2.6color_continuous_scale配色不是审美选择是信息编码规则Plotly的连续色标color_continuous_scale直接影响信息解读效率。默认的Viridis虽科学但业务方常抱怨“看不出深浅区别”。实战中我建立三原则业务一致性金融数据用红-绿红亏损绿盈利温度数据用蓝-红冷-热色盲友好禁用红绿色系改用plasma或cividis对比度强化用color_continuous_midpoint突出阈值。# 例用户满意度评分0-10060分为及格线 px.scatter(df, xage, ysatisfaction, colorsatisfaction, color_continuous_scaleRdYlGn, # 红黄绿 color_continuous_midpoint60) # 60分处为黄色效果低于60分偏红高于60分偏绿60分附近黄色过渡业务方一眼锁定风险人群。3.2.7update_layout()定制化不是锦上添花是消除认知噪音update_layout()是Plotly的终极武器用于移除所有干扰业务解读的元素fig px.line(df, xdate, ysales) fig.update_layout( titleQ3销售趋势, xaxis_title日期, yaxis_title销售额万元, showlegendFalse, # 单一线条无需图例 plot_bgcolorwhite, # 白色背景更易打印 fontdict(size12), xaxisdict(showgridTrue, gridwidth1, gridcolorlightgray), yaxisdict(showgridTrue, gridwidth1, gridcolorlightgray) )特别注意showlegendFalse当图表只有一条线时图例纯属干扰。我统计过去掉冗余图例、标题、边框后业务方平均阅读时间缩短40%。3.2.8range_slider与range_selector时间图表的导航仪对于长周期时间序列如3年日销量默认缩放体验极差。range_slider和range_selector提供专业级导航fig px.line(df, xdate, ysales) fig.update_layout( xaxis_rangeslider_visibleTrue, # 底部滑块 xaxis_rangebreaks[dict(bounds[sat, mon])] # 跳过周末 ) # 或用更高级的range_selector fig.update_xaxes( rangeselectordict( buttonslist([ dict(count1, label1m, stepmonth, stepmodebackward), dict(count6, label6m, stepmonth, stepmodebackward), dict(count1, labelYTD, stepyear, stepmodetodate), dict(stepall) ]) ) )效果图表顶部出现“1m/6m/YTD/All”按钮业务方一键切换时间范围比手动拖拽精准十倍。3.2.9template主题不是换肤是统一企业视觉规范template参数让图表风格与企业VI无缝对接# 定义企业模板一次配置全局生效 import plotly.graph_objects as go custom_template go.layout.Template( layoutgo.Layout( fontdict(familyHelvetica, Arial, size12), title_fontdict(size16, color#2c3e50), colorscale_sequentialpx.colors.sequential.Tealgrn ) ) px.defaults.template custom_template # 全局生效 # 后续所有px.*调用自动应用该模板 fig px.bar(df, xregion, ysales)这比每次update_layout()手动设置高效得多且保证所有图表风格一致避免“同一份数据不同人画出五种风格”的混乱。3.2.10config导出与交互的最终控制权config参数决定图表如何交付给终端用户fig.show(config{ displayModeBar: True, # 显示工具栏下载、缩放等 displaylogo: False, # 隐藏Plotly logo modeBarButtonsToAdd: [ drawline, drawopenpath, eraseshape # 添加绘图工具 ], scrollZoom: True, # 允许滚轮缩放 staticPlot: False # 禁用静态模式 })关键配置staticPlotFalse确保交互功能可用displayModeBarTrue让业务方可自行下载PNG/SVGscrollZoomTrue提升长图表浏览体验。这些细节决定图表是“摆设”还是“生产力工具”。4. 实操过程从零构建电商用户行为分析看板含完整代码与避坑指南4.1 数据准备模拟真实电商场景的四张表我们以电商用户行为分析为例构建一个可落地的看板。真实场景中数据分散在四张表users.csv用户基本信息user_id, register_date, region, device_typeevents.csv用户行为日志user_id, event_time, event_type, page_urlorders.csv订单表order_id, user_id, order_time, amount, product_categoryproducts.csv商品表product_id, product_name, category, price为演示我们用Pandas生成模拟数据实际项目中替换为数据库查询import pandas as pd import numpy as np import plotly.express as px import plotly.graph_objects as go from datetime import datetime, timedelta # 设置随机种子保证可重现 np.random.seed(42) # 生成10000个用户 n_users 10000 users pd.DataFrame({ user_id: range(1, n_users 1), register_date: pd.date_range(2023-01-01, periodsn_users, freqH), region: np.random.choice([华东, 华南, 华北, 西南], n_users), device_type: np.random.choice([iOS, Android, Web], n_users, p[0.4, 0.45, 0.15]) }) # 生成50万条行为日志 n_events 500000 events pd.DataFrame({ user_id: np.random.choice(users[user_id], n_events), event_time: pd.date_range(2023-01-01, periodsn_events, freq10S), event_type: np.random.choice([view_product, add_to_cart, checkout, purchase], n_events, p[0.6, 0.2, 0.15, 0.05]), page_url: np.random.choice([/home, /product/123, /cart, /checkout], n_events) }) # 生成2万条订单 n_orders 20000 orders pd.DataFrame({ order_id: range(1, n_orders 1), user_id: np.random.choice(users[user_id], n_orders), order_time: pd.date_range(2023-01-01, periodsn_orders, freq2H), amount: np.random.lognormal(8, 0.5, n_orders), # 对数正态分布模拟真实消费 product_category: np.random.choice([手机, 电脑, 配件, 数码], n_orders) }) # 生成商品表 products pd.DataFrame({ product_id: range(1, 101), product_name: [fProduct_{i} for i in range(1, 101)], category: np.random.choice([手机, 电脑, 配件, 数码], 100), price: np.random.lognormal(7, 0.4, 100) })4.2 Pandas数据整形构建分析宽表的七步法4.2.1 步骤1时间字段标准化与衍生# 统一时间格式所有表 users[register_date] pd.to_datetime(users[register_date]) events[event_time] pd.to_datetime(events[event_time]) orders[order_time] pd.to_datetime(orders[order_time]) # 衍生时间特征为后续分组做准备 users[register_month] users[register_date].dt.to_period(M) events[event_date] events[event_time].dt.date events[event_hour] events[event_time].dt.hour orders[order_month] orders[order_time].dt.to_period(M)4.2.2 步骤2用户行为聚合关键指标计算# 计算每个用户的行为频次 user_events events.groupby(user_id).agg( total_events(event_type, count), view_count(event_type, lambda x: (x view_product).sum()), cart_count(event_type, lambda x: (x add_to_cart).sum()), purchase_count(event_type, lambda x: (x purchase).sum()) ).reset_index() # 计算用户首购时间用于留存分析 first_purchase orders.groupby(user_id)[order_time].min().rename(first_purchase_time) user_events user_events.merge(first_purchase, onuser_id, howleft) # 标记是否付费用户 user_events[is_paying_user] (user_events[first_purchase_time].notna()).astype(int)4.2.3 步骤3订单聚合与用户画像融合# 订单聚合每个用户的总消费、订单数、平均客单价 user_orders orders.groupby(user_id).agg( total_orders(order_id, count), total_amount(amount, sum), avg_order_value(amount, mean) ).reset_index() # 融合用户画像 user_profile users.merge(user_events, onuser_id, howleft) \ .merge(user_orders, onuser_id, howleft) # 填充空值未下单用户 user_profile[total_orders] user_profile[total_orders].fillna(0) user_profile[total_amount] user_profile[total_amount].fillna(0) user_profile[avg_order_value] user_profile[avg_order_value].fillna(0)4.2.4 步骤4构建留存分析矩阵Cohort Analysis# 创建用户分群按注册月份 user_profile[cohort] user_profile[register_date].dt.to_period(M) # 计算每个用户在各时间段的活跃情况 # 这里简化用订单表生成活跃标记实际中可用行为日志 orders_with_cohort orders.merge( users[[user_id, register_date]], onuser_id ) orders_with_cohort[cohort] orders_with_cohort[register_date].dt.to_period(M) orders_with_cohort[