最近帮一个做休闲游戏的朋友搭团队数据看板,需求说简单也简单:每天打开一个网页,看一眼昨天新增了多少玩家、活跃怎么样、充值有没有异常,然后想再往下看一层,比如到底是哪个买量渠道不来了,还是老玩家不活跃了,或者是某个版本更新后留存垮了。一开始他们想用Excel做日报,但玩了不到两周就发现,Excel只能回答“是多少”,很难回答“为什么”。真正需要的是一个能按时间、渠道、版本、玩家类别随意切换视角的分析工具。
我用 Python + Streamlit 做了一套多维度游戏玩家数据分析面板。整个项目不算重,但它把数据清洗、指标口径、聚合计算、可视化交互全部串了起来,做完以后运营同事不用提SQL,自己点左边筛选器就能对比趋势和拆解问题。如果你在做游戏数据分析、日常要写运营报表,或者刚学完 Pandas 想找一个能落地的练手项目,这篇文章都值得看。下面我会把从需求拆解到代码实现,再到上线部署踩过的坑完整复盘一遍,代码部分基本可以直接抄去改。
1. 这个面板要解决什么问题:游戏玩家数据的多维度洞察为什么难
1.1 游戏运营里的“多维度”到底在分析什么
先对齐一个概念。游戏玩家数据分析和普通网站流量分析不太一样,玩家一条数据里通常能拆出很多属性。以我这边用的演示数据结构为例,一张玩家信息表里有 player_id、player_name、channel、device_platform、register_date、register_version、first_recharge_time、total_recharge 等字段;另一张每日活跃流水表里有 log_date、player_id、login_count、payment_amount、payment_count。两张表通过 player_id 关联。
所谓多维度,就是把玩家按不同角度切分后观察指标。常规做法至少会从下面几个维度下手:
- 时间维度:按日、周、月看新增和活跃趋势,判断版本更新、活动投放前后变化;
- 渠道维度:对比不同买量渠道带来的新增用户质量,而不只是看Cost和Install量;
- 版本维度:某次客户端发版后,留存、付费是否出现波动,这种问题在游戏运营中经常发生;
- 玩家生命周期:按注册日期分群,观察新玩家在注册后第 1 天、第 3 天、第 7 天是否还会回来;
- 设备与付费分层:iOS 和 Android 的付费习惯不同,首充玩家和重复付费玩家的行为路径也不同。
这里必须说一句:如果分析只停留在“昨天 DAU 掉了 3%”,那基本没有决策意义。真正的价值在于再点一下渠道筛选器,发现掉的是某个特定广告渠道的新增;或者再切一下版本维度,发现是上周发的新版本在低端机上崩溃率变高,从而导致这部分玩家流失。所以,多维度分析不是做一堆花花绿绿的图表,而是让业务人员能自己“下钻”定位问题。
1.2 为什么最终选了 Streamlit 而不是 BI 大屏或 Flask
项目启动前我也纠结过选型。Excel 透视表其实能应付一部分需求,但多个运营同时要看、每天要更新、还要能联动筛选,Excel 的协作体验太差了。Power BI 和 Tableau 在可视化上很强,但当时团队没有现成的 BI 授权,而且游戏数据的口径和埋点经常调整,每次改指标都要等 IT 部门改数据模型,效率太低。如果自己用 Flask 写,一个简单筛选器就得涉及前端表格、表单提交、Ajax 刷新,开发周期完全不可控。
后面我把方案缩小到 Dash 和 Streamlit 之间。Dash 功能确实更全面,适合做定制化要求高的大屏项目,但它的回调机制对初学者不太友好,写一个联动筛选器要拆成好几个 @callback,数据流一旦复杂起来,排查问题很费劲。Streamlit 的交互模型是“脚本自上而下重新执行”,页面放了多选框,用户一选,下面所有数据处理代码自动跟着变,不需要额外写事件绑定。对我这种以数据处理为主、不想花大量时间写前端的人来说,Streamlit 的开发效率高一个量级。
不同方案的适用场景我整理在下面,方便你按自己的团队情况选:
| 方案 | 上手成本 | 交互分析体验 | 前端自定义程度 | 适合场景 |
|---|---|---|---|---|
| Excel 透视表 | 最低 | 单人零散看数 | 低 | 临时取数、个人分析 |
| Power BI / Tableau | 中等 | 好,但需购买授权 | 高 | 企业级统一 BI 报表 |
| Dash | 中高 | 好,但回调复杂 | 高 | 可定制化要求高的大屏项目 |
| Flask + 前端 | 较高 | 开发成本高 | 高 | 完整 Web 产品 |
| Streamlit | 低 | 高,适合内部工具 | 中 | 数据分析面板、内部运营看板 |
这个项目最后面向的使用者是运营和策划,他们需要的是“打开就能看、能自己筛选、更新速度快”的内部工具,而不是对外发布的大型网站。Streamlit 明显更合适。
1.3 动手前的基础准备和运行环境
我假设你已经会 Python 基础语法和简单的 Pandas 操作,不需要很精通,但至少要会 pd.read_csv()、groupby、merge 这几个操作。Streamlit 本身不算一种新语言,就是一个 Web 框架,装好依赖后把脚本跑起来就行。
建议新建一个虚拟环境,不要图省事直接装到系统 Python 里:
bash复制python -m venv .venv
# Windows
.venv\Scripts\activate
# Linux / macOS
source .venv/bin/activate
pip install streamlit pandas numpy plotly
很多新手在这些步骤上翻车,我先提个醒:Windows 环境如果 python 命令提示不是内部或外部命令,多半是安装时没有勾选 Add Python to PATH,可以改用 py -m pip 来装包。Python 版本推荐 3.10 或更高,太低的话一些新版 Streamlit API 用不了。装好后在命令行执行 streamlit hello,浏览器能弹出自带 Demo,就说明环境没问题。
requirements.txt 里写上四行依赖就行,核心其实只有 streamlit、pandas、plotly 三个,numpy 是 pandas 的关联依赖,显式列出来方便锁定版本。
code复制streamlit>=1.30.0
pandas>=2.0.0
numpy>=1.24.0
plotly>=5.18.0
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 整体架构与数据模型设计:数据分析逻辑必须和界面展示解耦
2.1 目录怎么拆:一上来就写成单文件后面会想哭
很多 Streamlit 示例代码只有一个 app.py,全部逻辑堆在一起。做个 Demo 无所谓,但如果要接入多张表、多组指标、多个图表,单文件很快就会变成又长又乱的意大利面。我的建议是第一版就按功能拆模块,哪怕代码量不大也要拆。
我实际用到的目录结构大致是这样的:
code复制game_analytics/
├── app.py # Streamlit 入口,负责页面布局
├── modules/
│ ├── __init__.py
│ ├── config.py # 常量、路径、指标口径说明
│ ├── data_loader.py # 数据读取和清洗
│ ├── metrics.py # 指标计算函数
│ └── charts.py # Plotly 图表封装
├── data/
│ ├── player_info.csv
│ └── login_log.csv
└── requirements.txt
app.py 里只放页面框架和筛选状态,比如 st.sidebar、st.columns、st.plotly_chart;data_loader.py 负责把 CSV 读进来并清洗;metrics.py 放 DAU、留存率、ARPPU 等计算函数;charts.py 把 DataFrame 转换成 Plotly 图表对象。这样做的核心好处是,指标计算逻辑可以被不同页面重复调用,以后要加维度或新指标,不需要在页面代码里到处翻。
2.2 数据清洗:先处理好这几件事再谈可视化
不夸张地说,我第一次拿到游戏数据时差点被脏数据坑惨。日期列是字符串格式、玩家 ID 有重复、测试账号混在正常玩家里面,这些如果不处理干净,后面画出来的留存曲线和付费率全都会失真。所以数据加载这一步必须当成正式功能来做,而不是随手 read_csv 就完事。
玩家信息表的加载函数我会加上以下逻辑:
python复制# modules/data_loader.py
import pandas as pd
TEST_KEYWORDS = ("test", "internal", "robot", "_debug")
def load_player_info(path: str) -> pd.DataFrame:
df = pd.read_csv(path)
# 1. 列名统一小写并去掉空格
df.columns = [c.strip().lower().replace(" ", "_") for c in df.columns]
# 2. 日期字段统一转换
df["register_date"] = pd.to_datetime(df["register_date"])
# 3. 剔除测试账号
df = df[~df["player_name"].str.contains(
"|".join(TEST_KEYWORDS), case=False, na=False
)]
# 4. player_id 去重
df = df.drop_duplicates(subset=["player_id"])
return df
这里几条规则说下理由:
- 列名统一小写,是因为后续写筛选代码时不想记变量到底是
Channel还是channel,统一以后少踩 KeyError; player_id我倾向于用字符串处理,不要转成 int。原因是有时候 ID 会是一串很长的数字,转 int 后可能丢失精度,而且如果有前导零,转完就没了;- 剔除测试账号,不能只靠名字匹配,因为有些内部员工账号也叫得很正式。更稳妥的做法是先剔除
channel列中包含internal的渠道,再在数据层维护一张黑名单 ID 表。测试数据对留存和付费的污染很明显,尤其会把某天新增人数抬得特别高。
活跃流水表的清洗相对简单,但日期字段也要统一:
python复制def load_login_log(path: str) -> pd.DataFrame:
df = pd.read_csv(path)
df["log_date"] = pd.to_datetime(df["log_date"])
df["player_id"] = df["player_id"].astype(str)
# 如果存在空充值字段,填 0
df["payment_amount"] = df["payment_amount"].fillna(0)
df["login_count"] = df["login_count"].fillna(0)
return df
注意时区问题。如果游戏服用的是东八区统计“一天”,那么所有日期转换都要统一指定时区,不要混用。有些服务器日志存的是 UTC 时间,如果直接把 UTC 当天当成自然日统计,每天 0 点到 8 点的玩家会被划到前一天,这会直接导致数据出现偏移。
2.3 指标口径先定清楚,不然团队里每个人说的 DAU 可能不是一个数
游戏数据分析最容易吵架的地方,不是计算代码写错,而是指标口径没有统一。比如 DAU,有人按“当天有登录日志的唯一玩家数”算,有人按“当天打开过 App 的启动设备数”算,这两个口径在某些场景下会差不少。再有新增玩家,到底是“当天注册的账号数”还是“当天首次进入游戏的设备数”,结果也会不同。
我在 metrics.py 里会把口径直接写进函数注释,并在页面上展示“指标口径说明”折叠框。下面这段是核心 KPI 的计算逻辑:
python复制# modules/metrics.py
import pandas as pd
def calc_daily_kpis(log_df: pd.DataFrame, day: pd.Timestamp) -> dict:
"""
口径说明:
DAU = 当天有过登录行为的唯一 player_id 数
付费用户 = 当天 payment_amount > 0 的唯一 player_id 数
收入 = 当天 payment_amount 总和
ARPPU = 当天收入 / 当天付费用户数
"""
day_df = log_df[log_df["log_date"] == day]
dau = day_df["player_id"].nunique()
pay_users = day_df[day_df["payment_amount"] > 0]["player_id"].nunique()
revenue = float(day_df["payment_amount"].sum())
return {
"dau": dau,
"pay_users": pay_users,
"revenue": revenue,
"arppu": round(revenue / pay_users, 2) if pay_users > 0 else 0,
}
具体到不同业务,口径可能有微调,但团队里一定要有一份统一的说明。比如 ARPPU(每付费用户平均收入)和 ARPU(每活跃用户平均收入)很容易被搞混,前者分母是付费用户,后者分母是活跃用户。分析付费能力用 ARPPU,评估整体收入盘子用 ARPU。后面做图表时两者都要有,但不要放在同一个指标卡里让人疑惑。
2.4 给 Streamlit 加缓存:不加缓存,交互一次慢十秒
Streamlit 最大的特点是“交互即重跑”。用户点一个多选框,整个脚本都会从第一行重新执行一遍。如果每次重新执行都去读磁盘上的 CSV,数据量一大,界面转圈能转到人崩溃。所以数据读取和清洗函数必须加缓存。
我一般这样写:
python复制@st.cache_data(ttl=300, show_spinner=False)
def get_player_data():
return load_player_info("data/player_info.csv")
@st.cache_data(ttl=300, show_spinner=False)
def get_login_log():
return load_login_log("data/login_log.csv")
ttl=300 表示缓存 5 分钟,过了 5 分钟后用户再次操作时会自动重新读取数据。如果你的数据每天只在凌晨定时更新一次,ttl 可以不加,改成手动调 get_player_data.clear() 清缓存。但实际团队使用中,数据管道不一定准点跑完,我建议保留 ttl,宁可多读一次也不让运营看到旧数据。
要特别注意一点:@st.cache_data 适合缓存 DataFrame 这类可序列化对象,不要缓存返回 Streamlit 组件或 Plotly Figure 的函数,否则容易出现奇怪的缓存命中问题。缓存只放在数据加载层最合适。每次交互重跑时,页面会根据最新的筛选条件重新调指标计算函数。
3. 核心代码实现:从侧边栏筛选器到可视化面板
3.1 Streamlit 侧边栏筛选器怎么写才不坑
多维度分析能不能真正落地,关键看筛选器是否好用。我的做法是把所有筛选条件都放在左侧 st.sidebar,主区域只负责展示结果。筛选条件一般包括日期范围、渠道、版本、设备平台。
一个完整的筛选模块长这样:
python复制import streamlit as st
import pandas as pd
st.set_page_config(page_title="游戏玩家数据分析", layout="wide")
players = get_player_data()
logs = get_login_log()
st.sidebar.header("分析条件")
# 日期范围,默认展示有数据的完整区间
min_date = players["register_date"].min().date()
max_date = logs["log_date"].max().date()
date_range = st.sidebar.date_input(
"日期范围",
value=(min_date, max_date),
min_value=min_date,
max_value=max_date,
)
# 渠道多选
channel_options = sorted(players["channel"].dropna().unique())
selected_channels = st.sidebar.multiselect("渠道", options=channel_options)
# 版本多选
version_options = sorted(players["register_version"].dropna().unique())
selected_versions = st.sidebar.multiselect("注册版本", options=version_options)
这里要记住一个坑:st.date_input 当传入 value=(start, end) 时返回的是包含两个日期的元组;但如果只传一个日期,返回值就是一个单独对象。我在刚开始写的时候没注意,导致后面过滤时报“长度不匹配”的错误。后面我们可以先用 len(date_range) 判断用户选的是不是完整区间。
拿到筛选条件之后,下一步是把筛选条件真正应用到原始数据上。关键在于先过滤玩家维度表,再通过玩家 ID 过滤日志表。因为 channel、register_version 这些属性只存在于玩家表上,日志表只有每天的行为流水:
python复制filtered_players = players.copy()
filtered_logs = logs.copy()
if len(date_range) == 2:
start_date = pd.Timestamp(date_range[0])
end_date = pd.Timestamp(date_range[1])
filtered_players = filtered_players[
(filtered_players["register_date"] >= start_date)
& (filtered_players["register_date"] <= end_date)
]
filtered_logs = filtered_logs[
(filtered_logs["log_date"] >= start_date)
& (filtered_logs["log_date"] <= end_date)
]
if selected_channels:
selected_ids = filtered_players[
filtered_players["channel"].isin(selected_channels)
]["player_id"]
filtered_players = filtered_players[
filtered_players["channel"].isin(selected_channels)
]
filtered_logs = filtered_logs[filtered_logs["player_id"].isin(selected_ids)]
if selected_versions:
selected_ids = filtered_players[
filtered_players["register_version"].isin(selected_versions)
]["player_id"]
filtered_players = filtered_players[
filtered_players["register_version"].isin(selected_versions)
]
filtered_logs = filtered_logs[filtered_logs["player_id"].isin(selected_ids)]
注意我在两次筛选后都重新取了 selected_ids,这个操作一定要放在玩家表被上一个筛选条件过滤之后。如果一开始就把所有渠道和版本条件都作用在原始玩家表上再取 ID 也可以,但代码会比较啰嗦。上面的写法更直观,每一步的操作对象都清晰。
还有一个常见的体验问题:如果用户没有选择任何渠道,默认应该看全部渠道,multiselect 返回空列表时不要做过滤。很多入门文章会写成“默认选中所有渠道”,其实不建议,因为渠道数量多时默认全选会让初始化很慢。我通常把“不限渠道”置于一个开关,用一个 st.checkbox("只看部分渠道") 来控制是否启用 multiselect,或者干脆允许空列表代表全部。
3.2 顶部指标卡:一屏掌握整体大盘
页面顶部放指标卡,适合做每日例行巡检。Streamlit 自带 st.metric 组件,可以展示数值和变化率。我把计算和展示分开写:
python复制# 取筛选范围内的最后一天作为“今日”,用于展示环比变化
latest_day = pd.Timestamp(end_date) if len(date_range) == 2 else logs["log_date"].max()
prev_day = latest_day - pd.Timedelta(days=1)
def calc_day_kpis(day):
day_df = filtered_logs[filtered_logs["log_date"] == day]
dau = day_df["player_id"].nunique()
pay_users = day_df[day_df["payment_amount"] > 0]["player_id"].nunique()
revenue = float(day_df["payment_amount"].sum())
return dau, pay_users, revenue
dau, pay_users, revenue = calc_day_kpis(latest_day)
dau_prev, pay_users_prev, revenue_prev = calc_day_kpis(prev_day)
dau_delta = (dau / dau_prev - 1) * 100 if dau_prev else 0
revenue_delta = (revenue / revenue_prev - 1) * 100 if revenue_prev else 0
col1, col2, col3, col4 = st.columns(4)
col1.metric("DAU", f"{dau:,}", delta=f"{dau_delta:.1f}% 较前一日")
col2.metric("当日付费人数", f"{pay_users:,}", delta=f"{pay_users - pay_users_prev:+,}")
col3.metric("当日收入", f"{revenue:,.0f}", delta=f"{revenue_delta:.1f}%")
col4.metric("ARPPU", f"{revenue / pay_users:.1f}" if pay_users else "0",
delta="--")
这里有一个经验:如果筛选范围是一个月而不是一天,顶部指标卡显示“较前一天”的意义就不大。更好的做法是给指标卡区域加一个开关,让用户选择是看“选区内最后一天”还是“选区内累计值”。累计值时,DAU 要改成选区内的活跃去重数,收入变成选区内总收入,ARPPU 等于总付费金额除以付费去重人数。业务人员如果想看投放活动期间整体表现,就会切到累计模式,两个模式都要保留,避免一个指标卡在不同的展示口径之间来回解释。
3.3 同期群留存分析:游戏玩家留存表的计算和热力图展示
留存分析是游戏数据分析里最核心的模块。平常我们说的次日留存、7 日留存,本质上是一种同期群分析:以注册日期为分群,观察这群玩家在被创建后的第 1 天、第 2 天……是否仍然活跃。
如果只有两张表,计算留存可以这样拆解:
- 找到每个玩家第一次出现日期,也就是注册日
register_date; - 把活跃日志按
player_id和register_date合并; - 计算
log_date和register_date之间相隔的天数cohort_day; - 按注册日期和
cohort_day做透视,统计唯一玩家数; - 除以每天的注册人数,得到留存率。
完整实现如下:
python复制def compute_retention(players_df: pd.DataFrame, logs_df: pd.DataFrame, max_day: int = 30) -> pd.DataFrame:
# 只保留需要的列,减少 merge 内存
player_base = players_df[["player_id", "register_date"]].rename(
columns={"register_date": "install_date"}
)
logs = logs_df[["player_id", "log_date"]]
merged = logs.merge(player_base, on="player_id", how="inner")
merged["cohort_day"] = (merged["log_date"] - merged["install_date"]).dt.days
merged = merged[(merged["cohort_day"] >= 0) & (merged["cohort_day"] <= max_day)]
# 每日新增玩家数
new_users = player_base.groupby("install_date")["player_id"].nunique()
# 透视表:行=注册日期,列=第N天活跃的玩家数
active_pivot = merged.groupby(
["install_date", "cohort_day"]
)["player_id"].nunique().unstack()
# 用每天新增人数做分母,得到留存率
retention = active_pivot.divide(new_users, axis=0) * 100
return retention.round(2)
这段代码有几个细节值得展开:
- 合并前把 players 表缩小到只留需要的两列,可以大幅减少内存占用。如果把渠道、版本、付费金额等几十列全带进去再 merge,连接后的临时 DataFrame 会大很多;
cohort_day可能出现负值,原因是日志时间早于注册时间。这种情况大概率是数据回补或时区问题,直接剔除比保留更安全;max_day=30限制预测区间,不加这个限制,透视表会多出一堆远期列,而且不少日期下没有数据,图会非常稀疏。
渲染热力图时,我用 Plotly 的 px.imshow,因为留存表本质是一个矩阵,热力图比独立的多根折线更直观,尤其是要看“最近注册的玩家质量是否变差”:
python复制import plotly.express as px
retention_df = compute_retention(filtered_players, filtered_logs, max_day=30)
# 只展示最近 90 天注册的同期群,否则图太长
retention_df = retention_df.tail(90)
fig = px.imshow(
retention_df,
text_auto=".0f",
labels={"x": "注册后第N天", "y": "注册日期", "color": "留存率(%)"},
title="新玩家分群留存热力图(按注册日期)",
color_continuous_scale="Blues",
)
fig.update_xaxes(title="注册后第N天")
st.plotly_chart(fig, use_container_width=True)
如果运营想看次日留存、7 日留存的趋势,可以从 retention_df 里抽出对应列,画一条折线。代码封装好了之后,切换视图只是换个参数的事:
python复制st.subheader("关键留存率趋势")
retention_cols = [1, 3, 7, 14, 30]
line_df = retention_df[[c for c in retention_cols if c in retention_df.columns]].copy()
line_df.index = line_df.index.strftime("%Y-%m-%d")
fig2 = px.line(line_df, labels={"value": "留存率(%)", "variable": "LTV日"})
st.plotly_chart(fig2, use_container_width=True)
这里必须给一个忠告:当某天新增人数太少时,第二天留存率会忽高忽低,比如某天只进来 5 个玩家,其中 3 个第二天上线,次日留存直接标成 60%,这个数字没有任何统计意义。建议在画图时加一个“日新增低于阈值自动标灰”的规则,或者干脆在计算时丢弃小于 50 人的日期。
3.4 渠道和版本对比:多维度洞察价值的直接体现
留存热力图回答的是“总体玩家质量随时间如何变化”,而业务上经常要问:哪个渠道带来的用户质量更好?哪个版本的玩家更容易付费?这时候就要做分组对比。
渠道维度的对比,我一般会做两个东西。第一个是新增规模和付费表现的汇总表与柱状图:
python复制channel_stats = filtered_players.groupby("channel").agg(
new_users=("player_id", "count"),
revenue=("total_recharge", "sum"),
)
channel_stats["arpu"] = channel_stats["revenue"] / channel_stats["new_users"]
channel_stats = channel_stats.sort_values("new_users", ascending=False)
fig3 = px.bar(
channel_stats.reset_index(),
x="channel",
y="new_users",
color="revenue",
labels={"channel": "渠道", "new_users": "新增玩家", "revenue": "累计收入"},
title="各渠道新增玩家与累计收入对比",
)
st.plotly_chart(fig3, use_container_width=True)
第二个是渠道留存对比。可以把 compute_retention 函数扩展出一个 group_col 参数,对每个渠道分别计算次日、7 日留存。最简单的做法是先按渠道 sub-group 做循环,然后把结果收集起来画成对比柱状图。这里不需要展示过多代码,思路是把前面留存计算函数增加分组参数:
python复制# 伪代码:对每个渠道单独算关键留存
for channel in selected_channels:
ch_players = filtered_players[filtered_players["channel"] == channel]
ch_logs = filtered_logs[filtered_logs["player_id"].isin(ch_players["player_id"])]
one_retention = compute_retention(ch_players, ch_logs, max_day=7)
result.append({
"channel": channel,
"d1": one_retention[1].mean(),
"d7": one_retention[7].mean(),
})
版本维度的分析思路类似,通常是先看新版本的留存是否低于旧版本,再看崩溃率和付费是否有变化。如果没有崩溃率数据,单从留存角度也能做出基本判断。实操中,如果数据量有几百万行,按渠道循环计算会有点慢,可以先 merge 玩家属性到日志里,再一次性 groupby(["channel", "cohort_day"]) 来做透视,性能会好很多。
3.5 付费行为分布:别被“平均付费”骗了
游戏付费数据有一个非常典型的分布:大量玩家不付费,少量玩家贡献绝大部分收入。如果直接算全服平均付费,数字会被头部大 R 拉得很高,参考意义有限。所以面板里我会放一个“付费金额分布图”,专门看付费玩家的充值区间。
Streamlit 里我用 Plotly 直方图,但会把 X 轴改成对数坐标:
python复制pay_df = filtered_logs[filtered_logs["payment_amount"] > 0][["player_id", "payment_amount"]]
pay_df = pay_df.groupby("player_id")["payment_amount"].sum().reset_index()
fig4 = px.histogram(
pay_df,
x="payment_amount",
nbins=50,
title="玩家累计付费金额分布(对数坐标)",
labels={"payment_amount": "累计付费金额"},
)
fig4.update_xaxes(type="log")
st.plotly_chart(fig4, use_container_width=True)
为什么要用对数坐标?因为多数玩家的累计付费集中在几十元档位,少量玩家冲到几千甚至几万,如果使用线性坐标,柱子会全部挤在最左侧,看不出任何结构。取对数
