找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖
Claude、GPT 海外模型 API 接入云原生前端项目实战教程50G互联网架构师面试指南
大模型全栈开发课程企业级DevOps全栈实践零基础产品经理就业课程

6165

积分

0

好友

787

主题
发表于 7 天前 | 查看: 1| 回复: 0

同样是一张表,有时要看谁贡献最多,有时要看变化发生在哪儿,有时还要判断:这个差异到底是业务变化,还是数据口径出了问题?

如果每次都从"找个好看的图"开始,往往改了半天颜色,最后还是说不清问题。

蓝星宇的《数据可视化设计指南:从数据到新知》(电子工业出版社,2023年)介绍了常见图表的选择、设计方法与实际应用,适合想把数据表达清楚的读者。这篇文章参考书中的思路,整理 50个案例,用 Python 完整实现,数据、代码和效果图都重新制作。

本文全部数据均为模拟数据。 收入、用户、价格、评分、环境读数和文本频次都不对应真实个人、机构或市场;模拟是为了让规律可控、计算可查,不能拿这些结果当作现实结论。

你可以把它当作一份按问题查找的长指南:先看要解决的事,再选图,最后改代码。每个案例都包含数据说明、实际效果、实现代码、读图结果和注意事项。50个案例不等于50种互不重叠的图表类型,同一种图形会在不同分析问题中承担不同任务。

一、先选工具,再把运行方式统一

这次使用哪些库

工具 在本文中的职责 适合的情况
Matplotlib 坐标、几何图元、注释与静态导出 需要细致控制版式和编码
Seaborn 分布、统计关系与矩阵图 希望用较少代码完成统计探索
Plotly 平行坐标、漏斗、蜡烛、曲面、气泡、旭日、桑基 需要悬浮提示、筛选、旋转或层级交互
NetworkX 树和网络结构、布局与中心性 先建模关系,再交给 Matplotlib 绘制
squarify / wordcloud 矩形布局 / 中文词云 处理专门图形的排布问题
NumPy / pandas / SciPy 模拟、整理、汇总与统计计算 为图提供可检查的数据和指标

不要把工具职责混起来:NetworkX 主要处理图结构,Kaleido 负责 Plotly 的静态导出,pandas 负责数据整理,它们并不都属于同一类绘图库。

本次实际运行环境锁定在资料包的 requirements_v1.0.0.txt 中。Matplotlib、Seaborn 和 Plotly 分别为 3.11.2、0.13.2、7.0.0,资料核对日期为 2026 年 9 月 13 日。锁定版本是为了复现本次结果,不代表读者必须一直使用这个版本。

库的用法依据官方资料:Matplotlib 示例库、Seaborn 接口、NetworkX 绘图说明。

下载后如何运行

配套资料地址: https://github.com/dxawdc/caige-wechat-articles/tree/main/articles/2026-09-13-python-visualization-50/v1.0.2

ZIP 下载: https://github.com/dxawdc/caige-wechat-articles/raw/refs/heads/main/downloads/python-visualization-50_v1.0.2.zip

文章中外链不便点击时,可以复制上面的完整地址到浏览器。资料包有 50 份独立案例脚本、模拟 CSV、PNG、SVG、指标记录和离线图表索引;7 个 Plotly 案例另外提供交互 HTML。源码与物料独立于本文保存。

在解压后的资料目录打开终端,Windows 使用:

python -m venv .venv
.venv\Scripts\python -m pip install -r requirements_v1.0.0.txt
.venv\Scripts\python run_all_v1_0_2.py

macOS/Linux 使用 .venv/bin/python 替换上述 Python 路径。只运行某个案例时,例如 03 和 45:

python run_all_v1_0_2.py 3 45

也可以直接运行 python cases/case_03_v1_0_2.py。本文代码与对应脚本一致,都依赖同目录的 common_v1_0_2.py,它负责公共导入、中文字体、统一样式和输出文件。公共模块完整代码放在文末附录,也已经包含在资料包里。

中文图表需要系统安装微软雅黑、思源黑体/Noto Sans CJK SC、黑体或苹方之一。资料包不携带商业字体。Plotly 导出 PNG/SVG 使用 Kaleido,并需要可用的 Chrome;可用 BROWSER_PATH 指向本机 Chrome。Plotly 静态导出文档说明了该依赖。

先明确四个规则

1、一行是什么。
用户、订单、日均值、关系边和文本词元不能混用一个计数口径。

2、分母是什么。
本月总量、初始用户、当前阶段人数,是不同的分母。

3、视觉通道是什么。
长度、面积、颜色、位置各自承担一个清楚的含义。

4、模拟能说明什么。
图形和计算可以复现,现实结论仍需要真实数据与合适的研究设计。

下面每例保留独立随机种子,运行顺序不会改变数据。确定性示例直接给出小表,带随机性的例子则明确分布与参数。输出 CSV 可用 Excel 查看,SVG 便于放大,交互 HTML 需要与同目录 plotly.min.js 一起保留。

二、50个案例,按分析问题查找

比较贡献与差异 · 01—10

01|排序条形图:先找出贡献最大的渠道

工具:Matplotlib。

渠道报表里有六个来源,我们先回答最直接的问题:谁贡献的已支付订单最多?数据是一行一个渠道,订单口径一致,既不混进访问量,也不把退款单重复计算。

案例01 排序条形图先找出贡献最大的渠道

看图结果: 视频渠道以 1670 笔排在第一,直播为 450 笔。这个例子先呈现贡献量,尚未比较获客成本。

实现思路: 先按订单数升序排列,再调用 barh,最大值自然落在最上方。这里只强调第一名,其他渠道使用同一颜色,读者不用先认六种颜色再比较长短。bar_label 把具体数量补在右边,图和表的优势就结合起来了。

"""案例 01:先找出贡献最大的渠道 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

rng, fig, ax = start(1, "")
df = pd.DataFrame(
    {
        "渠道": [
            "搜索",
            "社群",
            "视频",
            "推荐",
            "直播",
            "自然访问",
        ],
        "订单数": rng.integers(450, 1800, 6),
    }
).sort_values("订单数")
bars = ax.barh(
    df.渠道, df.订单数, color=[COLORS[1]] * 5 + [COLORS[0]]
)
ax.bar_label(bars, padding=4)
ax.set(
    xlabel="已支付订单数(笔)",
    xlim=(0, df.订单数.max() * 1.17),
)
finish(
    1,
    "先找出贡献最大的渠道",
    fig,
    df,
    {
        "最大渠道": df.iloc[-1].渠道,
        "订单总数": int(df.订单数.sum()),
    },
)

别忽略: 条形长度表示绝对数量,因此横轴从零开始。这个排名只能说明订单贡献,不能直接证明投放效率;要评估效率,还需要各渠道成本、客单价和转化口径。

02|分组柱状图:计划和实际,必须并排看

工具:Matplotlib。

假设四个团队各有计划交付量和实际交付量。我们关心的是每个团队完成了多少,以及实际是否达到计划,所以让两根柱子并排站在一起。

案例02 分组柱状图计划和实际并排看

看图结果: 内容完成 132/120=110%,产品完成 108/90=120%;销售虽然绝对交付量较大,仍未达到 140 项计划。

实现思路: x 是团队位置,width 是柱宽,两个系列分别向左右偏移半个柱宽。这样每根柱子都从零开始,可以直接比较同一团队的计划和实际。颜色表示系列,不表示不同团队。

"""案例 02:计划和实际,必须并排看 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

rng, fig, ax = start(2, "")
df = pd.DataFrame(
    {
        "团队": ["内容", "社群", "产品", "销售"],
        "计划": [120, 100, 90, 140],
        "实际": [132, 86, 108, 129],
    }
)
x = np.arange(len(df))
width = 0.34
for i, col in enumerate(["计划", "实际"]):
    bars = ax.bar(
        x + (i - 0.5) * width, df[col], width, label=col
    )
    ax.bar_label(bars, padding=3)
ax.set(
    xticks=x,
    xticklabels=df.团队,
    ylabel="验收交付量(项)",
    ylim=(0, 165),
)
ax.legend(ncol=2)
finish(
    2,
    "计划和实际,必须并排看",
    fig,
    df,
    {
        "完成率": dict(
            zip(df.团队, (df.实际 / df.计划).round(3))
        )
    },
)

别忽略: 这里不能把计划与实际堆叠,因为它们不是可以相加的两份业务量。原始表同时保留两列,完成率用实际除以计划另算,别把超额完成写成新增贡献。

03|堆叠柱状图:收入总量由谁贡献

工具:Matplotlib。

订阅、课程和咨询共同构成季度收入。此时我们既要看季度总收入,也想知道总量由哪些业务组成,堆叠柱状图就比较合适。

案例03 堆叠柱状图收入总量由谁贡献

看图结果: 季度总收入依次为 110、124、143、171 万元。Q4 比 Q1 多 61 万元,其中订阅增加 36 万元。

实现思路: 关键不是连续调用三次 bar,而是让 bottom 随着已绘制的系列累加。代码会核对每个季度的最终高度是否等于三项收入之和,并在内部标分项、顶部标合计。

"""案例 03:收入总量由谁贡献 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

rng, fig, ax = start(3, "")
df = pd.DataFrame(
    {
        "季度": ["Q1", "Q2", "Q3", "Q4"],
        "订阅": [60, 72, 81, 96],
        "课程": [32, 28, 40, 45],
        "咨询": [18, 24, 22, 30],
    }
)
bottom = np.zeros(len(df))
for col in ["订阅", "课程", "咨询"]:
    bars = ax.bar(df.季度, df[col], bottom=bottom, label=col)
    ax.bar_label(bars, label_type="center", color="white")
    bottom += df[col].to_numpy()
assert np.array_equal(bottom, df.iloc[:, 1:].sum(axis=1))
for x, total in enumerate(bottom):
    ax.text(x, total + 3, f"{total:g}", ha="center")
ax.set(ylabel="收入(万元)", ylim=(0, 210))
ax.legend(ncol=3)
finish(
    3,
    "收入总量由谁贡献",
    fig,
    df,
    {"季度合计": bottom.tolist()},
)

别忽略: 堆叠之后,除最底层外,各业务没有共同起点,细微差距不容易判断。需要精确比较咨询收入的季度变化时,应补一张折线图,不能只凭上层色块的位置判断增长。

04|哑铃图:一次改版,谁的等待时间下降最多

工具:Matplotlib。

一次流程改版涉及注册、审核、结算、退款和导出。这里用模拟的改版前后平均等待时间,观察哪条流程缩短最多,而不是把两张独立排名表来回对照。

案例04 哑铃图一次改版谁的等待时间下降最多

看图结果: 退款等待从 60 分钟降到 38 分钟,减少 22 分钟,是五条流程中变化最大的一条。

实现思路: 水平线连接同一流程的两个观测,两个端点分别代表改版前和改版后。按减少分钟数排序,可以把改善幅度这个问题直接放进视觉顺序里,右侧文字补足具体差值。

"""案例 04:一次改版,谁的等待时间下降最多 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

rng, fig, ax = start(4, "")
df = pd.DataFrame(
    {
        "流程": ["注册", "审核", "结算", "退款", "导出"],
        "改版前": [18, 45, 32, 60, 25],
        "改版后": [12, 28, 24, 38, 22],
    }
)
df["减少分钟"] = df.改版前 - df.改版后
df = df.sort_values("减少分钟")
y = np.arange(len(df))
ax.hlines(y, df.改版后, df.改版前, color="#B9C5C0", linewidth=3)
ax.scatter(df.改版前, y, s=95, label="改版前")
ax.scatter(df.改版后, y, s=95, label="改版后")
for i, row in enumerate(df.itertuples()):
    ax.text(
        row.改版前 + 2,
        i,
        f"减少 {row.减少分钟} 分钟",
        va="center",
        fontsize=10,
    )
ax.set(
    yticks=y,
    yticklabels=df.流程,
    xlabel="平均等待时间(分钟)",
    xlim=(0, 88),
)
ax.legend()
finish(4, "一次改版,谁的等待时间下降最多", fig, df)

别忽略: 两端数据应来自可比时间窗口,平均值还可能受到用户结构变化影响。此图展示前后差异,不证明差异全部来自改版;真实评估最好同时记录样本数和请求分位数。

05|斜率图:两个月之间,渠道排名如何互换

工具:Matplotlib。

只比较一月和六月的渠道收入时,六个月的完整折线并不是必需的。斜率图保留两个时点,既能看方向,也能看渠道之间是否发生交叉。

案例05 斜率图两个月之间渠道排名如何互换

看图结果: 社群从 28 万元升至 39 万元,超过原先 36 万元的搜索;视频也从 18 万元升至 34 万元。

实现思路: 每个渠道只有两个端点,直接把名称和值写在两端,省去读者寻找图例的过程。社群和视频的上升线,与搜索的下降线形成了明显对比。

"""案例 05:两个月之间,渠道排名如何互换 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

rng, fig, ax = start(5, "")
df = pd.DataFrame(
    {
        "渠道": ["搜索", "社群", "视频", "推荐"],
        "一月": [36, 28, 18, 24],
        "六月": [30, 39, 34, 21],
    }
)
for i, row in enumerate(df.itertuples()):
    ax.plot(
        [0, 1],
        [row.一月, row.六月],
        "o-",
        color=COLORS[i],
        lw=2,
    )
    ax.text(
        -0.08,
        row.一月,
        f"{row.渠道}{row.一月}",
        ha="right",
        va="center",
    )
    ax.text(
        1.08, row.六月, f"{row.六月}{row.渠道}", va="center"
    )
ax.set(
    xticks=[0, 1],
    xticklabels=["一月", "六月"],
    xlim=(-0.6, 1.6),
    ylim=(14, 43),
    ylabel="新客户收入(万元)",
)
finish(5, "两个月之间,渠道排名如何互换", fig, df)

别忽略: 两点之间的线只是连接关系,不代表中间月份线性增长。遇到端点密集的数据,应减少系列或错开标签,不能为了避让而改变真实纵坐标。

06|雷达图:选工具时,把能力放在同一把尺子上

工具:Matplotlib。

多维度选型时,先约定评价量表,比先挑一个漂亮雷达图更重要。本例比较两个虚构方案的易用、定制、交互、导出和协作得分,五项都采用 0 到 100 且越高越好的规则。

案例06 雷达图选工具时把能力放在同一把尺子上

看图结果: 方案 A 在定制与导出上较高,方案 B 在交互与协作上较高。这只是演示评分表中的取舍,没有预设一个全能赢家。

实现思路: 五个维度均匀分布在圆周上,最后把第一个点接回去,轮廓才会闭合。轻透明填充只帮助区分方案,主要判断仍然来自每个维度的端点。

"""案例 06:选工具时,把能力放在同一把尺子上 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

rng, fig, ax = start(6, "", subplot_kw={"projection": "polar"})
df = pd.DataFrame(
    {
        "维度": ["易用", "定制", "交互", "导出", "协作"],
        "方案A": [80, 90, 55, 92, 65],
        "方案B": [88, 65, 92, 78, 86],
    }
)
theta = np.linspace(0, 2 * np.pi, len(df), endpoint=False)
for name in ["方案A", "方案B"]:
    angles = np.r_[theta, theta[0]]
    values = np.r_[df[name], df[name].iloc[0]]
    ax.plot(angles, values, "o-", label=name)
    ax.fill(angles, values, alpha=0.08)
ax.set(
    theta_offset=np.pi / 2,
    theta_direction=-1,
    xticks=theta,
    xticklabels=df.维度,
    ylim=(0, 100),
)
ax.set_yticks([25, 50, 75, 100])
ax.legend(loc="upper right", bbox_to_anchor=(1.4, 1.1))
finish(6, "选工具时,把能力放在同一把尺子上", fig, df)

别忽略: 这些得分是演示用的主观评分,不能当成任何真实库的测评结果。雷达多边形面积会受到维度顺序影响,不适合用面积大小给方案做综合排名。

07|平行坐标图:平行坐标,寻找多指标的折中方案

工具:Plotly。

质量高、成本低、延迟小,三个目标通常需要取舍。我们构造 12 个方案,把原始质量、成本和延迟放在同一张图里寻找相近的折中方案。

案例07 平行坐标图寻找多指标的折中方案

看图结果: 成本 10 至 30 万元映射为 1 至 0 的成本优势,延迟 100 至 800 毫秒映射为 1 至 0 的速度优势。三轴高处具有一致方向。

实现思路: 为了让方向一致,成本和延迟转换成优势分数,三条轴都表示越高越好。归一化使用事先约定的范围,而不是偷偷根据当前样本的最小最大值调整;原始数据和转换结果分别导出。

"""案例 07:平行坐标,寻找多指标的折中方案 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

import plotly.graph_objects as go

rng = np.random.default_rng(20260920)
df = pd.DataFrame(
    {
        "方案": np.arange(1, 13),
        "质量": rng.uniform(70, 96, 12),
        "成本": rng.uniform(10, 30, 12),
        "延迟": rng.uniform(100, 800, 12),
    }
)
scaled = pd.DataFrame(
    {
        "质量↑": (df.质量 - 70) / 26,
        "成本优势↑": (30 - df.成本) / 20,
        "速度优势↑": (800 - df.延迟) / 700,
    }
)
fig = go.Figure(
    go.Parcoords(
        line=dict(
            color=df.方案,
            colorscale="Viridis",
            showscale=True,
            colorbar=dict(title="方案编号"),
        ),
        labelfont=dict(size=20),
        dimensions=[
            dict(
                label=c,
                values=scaled[c],
                range=[0, 1],
                tickvals=[0, 0.25, 0.5, 0.75, 1],
                ticktext=["0", "0.25", "0.50", "0.75", "1"],
            )
            for c in scaled
        ],
    )
)
finish(
    7,
    "平行坐标,寻找多指标的折中方案",
    fig,
    {"raw": df, "scaled": scaled},
)

别忽略: Plotly 的交互版可以拖动轴上的区间筛选方案。截图只能展示静态形状,颜色表示方案编号,没有好坏含义。归一化会隐藏原始单位,实际选型时必须回到原始成本与延迟表。

交互版在资料包 outputs/07_v1.0.2.html,可在本地浏览器打开。

08|比例圆面积图:圆面积编码,数值翻倍不能把半径翻倍

工具:Matplotlib。

四个项目的调用量分别为 100、200、400、800。我们希望圆的面积也呈现 1 比 2 比 4 比 8 的关系,而不是让最大值在视觉上被夸大。

案例08 比例圆面积图数值翻倍不能把半径翻倍

看图结果: 四个圆的面积比例为 1:2:4:8;最大圆半径约为最小圆的 2.83 倍,而不是 8 倍。

实现思路: 圆面积等于 πr²,所以半径必须和数值的平方根成正比。代码直接创建 Circle,使用相同坐标尺度,并对面积除以原值的结果做一致性检查。

"""案例 08:圆面积编码,数值翻倍不能把半径翻倍 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

from matplotlib.patches import Circle

rng, fig, ax = start(8, "", size=(8, 4))
df = pd.DataFrame(
    {
        "项目": ["甲", "乙", "丙", "丁"],
        "调用量": [100, 200, 400, 800],
    }
)
df["半径"] = np.sqrt(df.调用量 / 800) * 0.85
for i, row in enumerate(df.itertuples()):
    ax.add_patch(
        Circle((i * 2, 0), row.半径, color=COLORS[i], alpha=0.8)
    )
    ax.text(
        i * 2, -1.08, f"{row.项目}:{row.调用量}", ha="center"
    )
assert np.allclose(
    np.pi * df.半径**2 / df.调用量, np.pi * 0.85**2 / 800
)
ax.set(xlim=(-1.2, 7.2), ylim=(-1.5, 1.2), aspect="equal")
ax.axis("off")
finish(
    8,
    "圆面积编码,数值翻倍不能把半径翻倍",
    fig,
    df,
    {"面积比例": "1:2:4:8", "半径比例": "1:√2:2:√8"},
)

别忽略: 如果把数值直接当半径,800 相对于 100 会产生 64 倍面积,而正确关系只有 8 倍。圆形适合表达量级与视觉焦点;需要精确排名时,排序条形图仍更容易读。

09|日历热力图:日历热力图,周末和工作日有何区别

工具:Seaborn。

访问量每天都有变化,但按日期画一条线,不一定能看清星期效应。日历热力图把日期排回每周七天,让周末与工作日自然对齐。

案例09 日历热力图周末和工作日有何区别

看图结果: 8 月 1 日是星期六,因此第一行只在周六、周日有日期。白色空位没有被当作零访问参与色阶。

实现思路: 第一天的星期偏移决定它落在哪个格子,因此不能把 1 号永远放在第一列。没有日期的格子保留 NaN 并遮罩,格内同时写日期和访问量,连续色阶只编码数量。

"""案例 09:日历热力图,周末和工作日有何区别 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

rng, fig, ax = start(9, "", size=(8, 4.5))
dates = pd.date_range("2026-08-01", "2026-08-31")
df = pd.DataFrame(
    {
        "日期": dates,
        "访问量": rng.poisson(350, 31)
        + (dates.dayofweek >= 5) * 120,
    }
)
grid = np.full((6, 7), np.nan)
labels = np.full((6, 7), "", dtype=object)
for row in df.itertuples():
    pos = row.日期.day - 1 + dates[0].dayofweek
    r, c = divmod(pos, 7)
    grid[r, c] = row.访问量
    labels[r, c] = f"{row.日期.day}日\n{row.访问量}"
sns.heatmap(
    grid,
    annot=labels,
    fmt="",
    cmap="YlGnBu",
    mask=np.isnan(grid),
    ax=ax,
    linewidths=3,
    cbar_kws={"label": "访问量(次)"},
    xticklabels=list("一二三四五六日"),
    yticklabels=False,
)
ax.set(xlabel="星期", ylabel="2026年8月")
finish(9, "日历热力图,周末和工作日有何区别", fig, df)

别忽略: 模拟数据主动给周末增加了访问,因此图中出现周末更深的模式是生成机制的结果,不是对真实账号的观察。空白格不代表零访问,换月份时也要重新计算首日偏移。

10|单位点阵图:单位点阵,让交付量变得可数

工具:Matplotlib。

当数量不大时,把 24 项交付画成 24 个方块,比一根抽象的长柱子更容易逐个理解。这里一块就是一项验收交付,四个团队各自单独成组。

案例10 单位点阵图让交付量变得可数

看图结果: 数据、设计、研发、运营分别对应 24、17、32、21 个方块,四组总计 94 项交付。

实现思路: 用取余计算列号,用整除计算行号,每行最多八个方块。团队间预留空行,让组别边界清楚;右侧总数为读者提供快速核对,不要求大家真的一个个数完。

"""案例 10:单位点阵,让交付量变得可数 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

rng, fig, ax = start(10, "")
df = pd.DataFrame(
    {
        "团队": ["数据", "设计", "研发", "运营"],
        "交付量": [24, 17, 32, 21],
    }
)
for i, row in enumerate(df.itertuples()):
    k = np.arange(row.交付量)
    ax.scatter(
        k % 8, i * 6 + k // 8, s=90, marker="s", color=COLORS[i]
    )
    ax.text(8.2, i * 6 + 1.5, f"{row.交付量} 项", va="center")
ax.set(
    yticks=np.arange(4) * 6 + 1.5,
    yticklabels=df.团队,
    xticks=[],
    xlim=(-1, 11),
    aspect="equal",
)
ax.invert_yaxis()
ax.grid(False)
ax.set_xlabel("每个方块 = 1 项验收交付")
finish(10, "单位点阵,让交付量变得可数", fig, df)

别忽略: 这里的图标单位必须固定,不能有的团队一块代表一项,有的代表十项。点阵适合小数量与传播型解释,数万条明细应先汇总,别靠缩成密密麻麻的小点堆满画面。

观察时间与变化 · 11—20

11|折线与移动平均:趋势线先处理日期,再连接观测

工具:Matplotlib。

订单曲线同时包含增长、星期周期和随机波动。我们先保留每日原值,再叠加后向 7 日均值,分别回答每天发生什么和近期方向如何。

案例11 折线与移动平均趋势线先处理日期再连接观测

看图结果: 灰线保留星期波动,绿色 7 日均线更平稳。1 月 1 日至 6 日没有均线值,因为七日窗口尚不完整。

实现思路: rolling 的窗口包含当日及前六日,min_periods=7 要求满七个观测才给结果。前六天保持空值,避免读者以为整条平滑线使用了完全相同的样本窗口。

"""案例 11:趋势线先处理日期,再连接观测 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

rng, fig, ax = start(11, "")
df = pd.DataFrame(
    {"日期": pd.date_range("2026-01-01", periods=60)}
)
df["订单"] = np.round(
    160
    + np.arange(60) * 1.2
    + 20 * np.sin(np.arange(60) * 2 * np.pi / 7)
    + rng.normal(0, 8, 60)
)
df["7日均值"] = df.订单.rolling(7, min_periods=7).mean()
ax.plot(
    df.日期, df.订单, color="#B6C5BC", lw=1, label="每日订单"
)
ax.plot(df.日期, df["7日均值"], lw=2.5, label="后向7日均值")
ax.set(ylabel="订单数(笔)", xlabel="日期")
ax.legend()
fig.autofmt_xdate()
finish(
    11,
    "趋势线先处理日期,再连接观测",
    fig,
    df,
    {"窗口": "包含当日及前6日;前6日均值留空"},
)

别忽略: 平滑会降低噪声,也会滞后于突变。它不预测未来,不应该跨缺失日期直接把七行当七天;真实日数据要先补齐日期索引,并明确缺测处理规则。

12|面积图:面积图强调规模,不用渐变伪装增长

工具:Matplotlib。

月末已使用存储量是一个库存指标,我们想强调的是每个月的容量规模。面积图从零填到曲线,读者能同时看到水平高低和变化方向。

案例12 面积图强调规模不用渐变伪装增长

看图结果: 月末存储量从 33.0 TB 增加到 80.2 TB;这是存量路径,不是十二个月新增量的简单加总。

实现思路: 本例先模拟每个月的新增占用,再累加得到月末存量。fill_between 负责填充,plot 保留清晰轮廓,两个图层使用同一组数据,避免填充与折线错位。

"""案例 12:面积图强调规模,不用渐变伪装增长 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

rng, fig, ax = start(12, "")
df = pd.DataFrame(
    {
        "月份": np.arange(1, 13),
        "存储量": np.round(
            30 + np.cumsum(rng.uniform(2, 8, 12)), 1
        ),
    }
)
ax.fill_between(df.月份, df.存储量, alpha=0.22, color=COLORS[0])
ax.plot(df.月份, df.存储量, "o-", color=COLORS[0])
ax.set(
    xlabel="月份",
    ylabel="月末已使用存储量(TB)",
    ylim=(0, 110),
    xticks=df.月份,
)
finish(12, "面积图强调规模,不用渐变伪装增长", fig, df)

别忽略: 月份是离散观测,曲线之间的填充只是视觉连续。不要把十二个月的存量相加当作全年新增,也不要在需要细看小波动时靠截断面积基线放大变化。

13|阶梯图:阶梯图忠实表达规则生效的时间

工具:Matplotlib。

免费请求额度在政策调整日才发生变化。普通折线会在两个调整日之间画出斜坡,好像额度每天都在缓慢增加,这与规则不符。

案例13 阶梯图忠实表达规则生效的时间

看图结果: 额度在 2 月 15 日从 100 次调到 150 次,7 月 1 日调到 240 次。两个调整日之间保持水平。

实现思路: step 的 where='post' 表示当前节点的额度持续到下一个节点之前。用显式生效日期作横轴,同样的 150 次额度可以跨多个时间节点保持不变。

"""案例 13:阶梯图忠实表达规则生效的时间 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

rng, fig, ax = start(13, "")
df = pd.DataFrame(
    {
        "日期": pd.to_datetime(
            [
                "2026-01-01",
                "2026-02-15",
                "2026-04-01",
                "2026-07-01",
                "2026-09-01",
            ]
        ),
        "免费额度": [100, 150, 150, 240, 240],
    }
)
ax.step(df.日期, df.免费额度, where="post", lw=2.5)
ax.scatter(df.日期, df.免费额度, s=60)
ax.set(
    ylabel="每日免费请求额度(次)",
    xlabel="日期",
    ylim=(0, 280),
)
fig.autofmt_xdate()
finish(13, "阶梯图忠实表达规则生效的时间", fig, df)

别忽略: 边界约定要和业务一致:调整日当天已经执行新额度,还是次日执行?本例采用当天生效。最后一个日期也用于界定展示范围,不代表最后一档额度在该日停止。

14|极坐标周期折线:环形时间轴,比较一天中的访问节奏

工具:Matplotlib。

一天的 24 小时是周期数据,23 点之后会回到 0 点。把小时映射到圆周角度,可以把上午与晚间两段高峰放在同一圈里看。

案例14 环形时间轴比较一天中的访问节奏

看图结果: 图中出现约 10 点与 20 点两个高峰,0 点和 23 点的边界被连续连接,符合一天的周期结构。

实现思路: 0 点放在上方,时间顺时针前进;半径表示访问量,最后复制 0 点的数值闭合周期。这里选择折线,不让填充面积承担额外的数量含义。

"""案例 14:环形时间轴,比较一天中的访问节奏 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

rng, fig, ax = start(14, "", subplot_kw={"projection": "polar"})
h = np.arange(24)
v = (
    20
    + 100 * np.exp(-(((h - 10) / 3) ** 2))
    + 130 * np.exp(-(((h - 20) / 2.5) ** 2))
)
df = pd.DataFrame(
    {"小时": h, "访问量": np.round(v + rng.uniform(0, 8, 24))}
)
theta = h / 24 * 2 * np.pi
ax.plot(
    np.r_[theta, 2 * np.pi],
    np.r_[df.访问量, df.访问量.iloc[0]],
    lw=2,
)
ax.set(
    theta_offset=np.pi / 2,
    theta_direction=-1,
    xticks=np.arange(0, 24, 3) / 24 * 2 * np.pi,
    xticklabels=[f"{x:02d}:00" for x in range(0, 24, 3)],
    ylim=(0, 180),
)
ax.set_ylabel("访问量(次)", labelpad=30)
finish(14, "环形时间轴,比较一天中的访问节奏", fig, df)

别忽略: 环形布局适合真正的周期,不能把一段没有循环关系的年份强行首尾连接。想精确比较 9 点与 10 点的差距时,普通折线仍更直接。

15|堆叠面积图:堆叠面积,看渠道总量与结构一起变化

工具:Matplotlib。

搜索、社群和视频共同贡献每月获客量。本例把三个渠道的绝对数量叠起来,顶部轮廓表示总量,各层厚度表示各渠道贡献。

案例15 堆叠面积图看渠道总量与结构一起变化

看图结果: 三类获客量都增加,社群增长更快。顶部总量上升与内部份额变化需要分开理解。

实现思路: stackplot 需要按月份顺序传入每一个系列。代码固定系列顺序和颜色,避免同一个渠道在不同月份突然换到另一层,让读者误以为结构发生变化。

"""案例 15:堆叠面积,看渠道总量与结构一起变化 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

rng, fig, ax = start(15, "")
t = np.arange(1, 13)
df = pd.DataFrame(
    {
        "月份": t,
        "搜索": 90 + 5 * t,
        "社群": 30 + 7 * t,
        "视频": 10 + 2 * t**1.5,
    }
)
ax.stackplot(
    t,
    *[df[c] for c in ["搜索", "社群", "视频"]],
    labels=["搜索", "社群", "视频"],
    alpha=0.85
)
ax.set(
    xlabel="月份",
    ylabel="获客量(人)",
    xlim=(1, 12),
    ylim=(0, 430),
)
ax.legend(loc="upper left")
finish(15, "堆叠面积,看渠道总量与结构一起变化", fig, df)

别忽略: 上层边界的上升不一定意味着该渠道增加,它还可能来自底层增长。读渠道自身趋势要看带宽,读总量才看最上沿;如果关注份额,应先按月除以总量。

16|河流图:河流图观察主题热度如何消长

工具:Matplotlib。

入门、实战、部署三个话题的讨论热度,可能先后达到高峰。河流图通过连续色带展示主题如何接续出现,适合观察整体节奏。

案例16 河流图观察主题热度如何消长

看图结果: 入门话题先达到高峰,实战居中,部署靠后。三条带子的厚度反映各自热度,中心线位置没有业务单位。

实现思路: baseline='wiggle' 会调整堆叠基线,让色带的整体起伏更平顺。数据仍是非负讨论量,带宽才对应数量,色带上下位置不再有可直接读取的绝对意义。

"""案例 16:河流图观察主题热度如何消长 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

rng, fig, ax = start(16, "")
t = np.arange(24)
df = pd.DataFrame(
    {
        "周": t + 1,
        "入门": 60 * np.exp(-(((t - 4) / 5) ** 2)) + 10,
        "实战": 75 * np.exp(-(((t - 12) / 6) ** 2)) + 10,
        "部署": 80 * np.exp(-(((t - 20) / 5) ** 2)) + 10,
    }
)
ax.stackplot(
    df.周,
    df.入门,
    df.实战,
    df.部署,
    baseline="wiggle",
    labels=["入门", "实战", "部署"],
    alpha=0.85,
)
ax.set(
    xlabel="周次",
    ylabel="带宽代表讨论量;纵向位置无数值含义",
    yticks=[],
)
ax.legend(ncol=3, loc="upper left")
finish(16, "河流图观察主题热度如何消长", fig, df)

别忽略: 因此本图删除数值纵轴,并明确写出带宽含义。河流图不能胜任精确的逐周数值比较,也不能把负的基线坐标误解成负讨论量。

17|排名变化图:排名曲线,只回答位次问题

工具:Matplotlib。

收入增长不等于排名上升,因为其他团队可能增长更快。这里先保留四个团队的月收入,再逐月计算降序名次,单独回答谁超越了谁。

案例17 排名曲线只回答位次问题

看图结果: 乙在第 2 个月升到第一,甲在第 4 个月降到第三。原始收入表仍然保留,排名图没有替代金额信息。

实现思路: rank 沿行比较同一月份,ascending=False 让最高收入成为第 1 名。纵轴反转以后,第 1 名始终在上方,连线交叉就意味着相对位次改变。

"""案例 17:排名曲线,只回答位次问题 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

rng, fig, ax = start(17, "")
df = pd.DataFrame(
    {
        "月份": np.arange(1, 7),
        "甲": [100, 98, 95, 92, 96, 105],
        "乙": [90, 99, 101, 110, 115, 120],
        "丙": [80, 83, 90, 100, 101, 110],
        "丁": [70, 75, 88, 89, 93, 98],
    }
)
rank = df.set_index("月份").rank(
    axis=1, ascending=False, method="min"
)
for i, c in enumerate(rank):
    ax.plot(
        rank.index,
        rank[c],
        "o-",
        lw=2,
        label=c,
        color=COLORS[i],
    )
ax.set(
    yticks=[1, 2, 3, 4],
    xticks=df.月份,
    xlabel="月份",
    ylabel="月收入排名(1为最高)",
    ylim=(4.4, 0.6),
)
ax.legend(ncol=4)
finish(
    17,
    "排名曲线,只回答位次问题",
    fig,
    {"raw": df, "ranks": rank.reset_index()},
)

别忽略: 排名隐藏了收入差距:领先 1 万元和领先 100 万元可能只差一个名次。本例没有并列值;换成真实数据时,需要说明并列排名采用最小名次、平均名次还是其他规则。

18|甘特图:甘特图把开始时间与工期分开编码

工具:Matplotlib。

一个项目有需求、数据、开发、测试、上线五项任务。甘特图把开始日期放在横向位置,把持续天数放在长度上,可以看见并行工作与交接安排。

案例18 甘特图把开始时间与工期分开编码

看图结果: 开发与测试在 9 月 17 日至 20 日之间重叠;上线任务为 9 月 24 日起的两天,即区间[24日,26日)。

实现思路: 日期先转成 Matplotlib 日期坐标,barh 的 left 接开始时间,width 接工期。结束日期按开始加天数计算,明确采用左闭右开的区间,避免多算一天。

"""案例 18:甘特图把开始时间与工期分开编码 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

import matplotlib.dates as mdates

rng, fig, ax = start(18, "")
df = pd.DataFrame(
    {
        "任务": ["需求", "数据", "开发", "测试", "上线"],
        "开始": pd.to_datetime(
            [
                "2026-09-01",
                "2026-09-04",
                "2026-09-08",
                "2026-09-17",
                "2026-09-24",
            ]
        ),
        "天数": [5, 8, 12, 7, 2],
    }
)
df["结束"] = df.开始 + pd.to_timedelta(df.天数, unit="D")
ax.barh(
    df.任务, df.天数, left=mdates.date2num(df.开始), height=0.55
)
ax.xaxis_date()
ax.xaxis.set_major_formatter(mdates.DateFormatter("%m-%d"))
ax.invert_yaxis()
ax.set_xlabel("日历日期;区间为 [开始, 结束)")
finish(18, "甘特图把开始时间与工期分开编码", fig, df)

别忽略: 本例表达计划安排,没有建模人员容量、前置依赖或节假日。两根任务条重叠只能说明时间重叠,不能据此认定项目排程可执行。

19|瀑布图:瀑布图解释余额如何一步步变化

工具:Matplotlib。

期初有 100 万元,期间发生回款、采购、工资和租金,最后还剩多少?瀑布图把每次变动放在前一次余额的位置,形成一条能追溯的余额链。

案例19 瀑布图解释余额如何一步步变化

看图结果: 100+85−32−28−12=113 万元,期末总量柱和这条计算链完全一致。

实现思路: 正变动从旧余额向上长,负变动从新余额向上画,因此柱高用绝对值、底部取新旧余额的较小值。期初和期末是总量柱,从零起画。

"""案例 19:瀑布图解释余额如何一步步变化 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

rng, fig, ax = start(19, "")
df = pd.DataFrame(
    {
        "项目": [
            "期初",
            "回款",
            "采购",
            "工资",
            "租金",
            "期末",
        ],
        "变动": [100, 85, -32, -28, -12, 0],
    }
)
balance = 100
endpoints = [balance]
ax.bar(0, balance, color=COLORS[1])
for i in range(1, 5):
    delta = df.变动.iloc[i]
    new = balance + delta
    ax.bar(
        i,
        abs(delta),
        bottom=min(balance, new),
        color=COLORS[0] if delta > 0 else COLORS[4],
    )
    ax.plot(
        [i - 1 + 0.4, i - 0.4],
        [balance, balance],
        color="#AAB8B0",
        ls="--",
    )
    balance = new
    endpoints.append(balance)
ax.bar(5, balance, color=COLORS[1])
endpoints.append(balance)
for i, v in enumerate(df.变动):
    ax.text(
        i,
        max(endpoints[i], endpoints[max(0, i - 1)]) + 6,
        str(balance if i == 5 else v),
        ha="center",
    )
assert balance == df.变动.iloc[:5].sum()
ax.set(
    xticks=range(6),
    xticklabels=df.项目,
    ylabel="现金余额/变动(万元)",
    ylim=(0, 220),
)
finish(
    19,
    "瀑布图解释余额如何一步步变化",
    fig,
    df,
    {"期末余额": balance},
)

别忽略: 连接虚线帮助核对前后承接,断言核对期末是否等于期初加净变动。它适合可加减的余额分解;不同分母的转化率、彼此重叠的影响因素不能直接塞进这条加法链。

20|蜡烛图:蜡烛图展示同一周期的四个价格

工具:Plotly。

同一天有开盘、收盘、最高、最低四个价格。蜡烛图把开收盘之间画成实体,最高最低之间画成细线,压缩了单个交易日的信息。

案例20 蜡烛图展示同一周期的四个价格

看图结果: 25 个工作日各有四个价格,最高与最低始终包住开盘和收盘。周末留白属于时间轴,不是缺失的交易记录。

实现思路: 模拟时先构造开收盘,再让最高不低于二者最大值、最低不高于二者最小值。两个条件写成断言,能挡住最高价比收盘价还低这类常见假数据错误。

"""案例 20:蜡烛图展示同一周期的四个价格 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

import plotly.graph_objects as go

rng = np.random.default_rng(20260933)
n = 25
opens = 100 + np.cumsum(rng.normal(0, 1, n))
closes = opens + rng.normal(0, 1.4, n)
df = pd.DataFrame(
    {
        "日期": pd.bdate_range("2026-08-03", periods=n),
        "开盘": opens,
        "收盘": closes,
        "最高": np.maximum(opens, closes)
        + rng.uniform(0.2, 2, n),
        "最低": np.minimum(opens, closes)
        - rng.uniform(0.2, 2, n),
    }
)
assert (df.最高 >= df[["开盘", "收盘"]].max(axis=1)).all()
assert (df.最低 <= df[["开盘", "收盘"]].min(axis=1)).all()
fig = go.Figure(
    go.Candlestick(
        x=df.日期,
        open=df.开盘,
        high=df.最高,
        low=df.最低,
        close=df.收盘,
        increasing_line_color="#188568",
        decreasing_line_color="#CB6677",
    )
)
fig.update_layout(
    xaxis_rangeslider_visible=False,
    yaxis_title="虚构资产价格(元)",
)
finish(20, "蜡烛图展示同一周期的四个价格", fig, df)

别忽略: 本图绿色表示收盘高于开盘,红色表示下降,阅读前先确认配色习惯。数据对应虚构资产,只用于解释图形结构,不对应任何真实行情或交易建议。

交互版在资料包 outputs/20_v1.0.2.html,可在本地浏览器打开。

拆解构成与转化 · 21—25

21|环形占比图:环形图只放少量互斥类别

工具:Matplotlib。

收入只有四个互斥来源,总额明确为 100 万元时,环形图可以直观呈现整体构成。中间留白写总额,外侧写类别,百分比放在色带内部。

案例21 环形占比图只放少量互斥类别

看图结果: 订阅占 52%,咨询占 16%;环中明确给出 100 万元总额,读者不必猜测分母。

实现思路: pie 的 wedgeprops 设置环宽,底层依然按照各类别占总体的角度比例绘制。把类别数控制在少量范围,能避免标签挤在一侧和小扇区难辨的问题。

"""案例 21:环形图只放少量互斥类别 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

rng, fig, ax = start(21, "")
df = pd.DataFrame(
    {
        "来源": ["订阅", "课程", "咨询", "其他"],
        "收入": [52, 27, 16, 5],
    }
)
ax.pie(
    df.收入,
    labels=df.来源,
    autopct="%1.0f%%",
    startangle=90,
    wedgeprops={"width": 0.38, "edgecolor": "white"},
    pctdistance=0.81,
)
ax.text(
    0,
    0,
    "总收入\n100 万元",
    ha="center",
    va="center",
    fontsize=16,
)
ax.axis("equal")
finish(21, "环形图只放少量互斥类别", fig, df)

别忽略: 各项必须能相加且不重复。本例的 52% 既有占比,也有 100 万元这个分母;如果两个账户的总额不同,只比较圆环比例会遗漏规模差异。

22|100%堆叠条形图:百分比堆叠,规模不同也能比较结构

工具:Matplotlib。

四月到六月订单总量不同,但我们想比较新客与老客的结构,所以把每个月都归一到 100%。每条的长度一样,色块分界才是观察重点。

案例22 百分比堆叠条形图规模不同也能比较结构

看图结果: 四月、五月、六月的新客占比分别为 40%、45%、40%;六月新客绝对数量却是四月的两倍。

实现思路: 用每行的两项订单量之和作为分母,逐行相除,再乘 100。代码检查每条是否恰好加到 100,并明确标注三个月分别是 600、800 和 1200 笔订单。

"""案例 22:百分比堆叠,规模不同也能比较结构 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

rng, fig, ax = start(22, "")
df = pd.DataFrame(
    {
        "月份": ["四月", "五月", "六月"],
        "新客": [240, 360, 480],
        "老客": [360, 440, 720],
    }
)
shares = (
    df[["新客", "老客"]].div(
        df[["新客", "老客"]].sum(axis=1), axis=0
    )
    * 100
)
left = np.zeros(len(df))
for c in shares:
    bars = ax.barh(df.月份, shares[c], left=left, label=c)
    ax.bar_label(
        bars,
        labels=[f"{v:.1f}%" for v in shares[c]],
        label_type="center",
        color="white",
    )
    left += shares[c]
assert np.allclose(left, 100)
ax.set(
    xlim=(0, 100),
    xlabel="订单结构(%);各月分母分别为600、800、1200笔",
)
ax.legend(
    ncol=2, loc="upper center", bbox_to_anchor=(0.5, 1.14)
)
finish(22, "百分比堆叠,规模不同也能比较结构", fig, df)

别忽略: 新客占比下降不意味着新客订单下降。本例六月新客 480 笔,高于五月 360 笔,但占比从 45% 降到 40%;结构图必须和原始数量表一起保留。

23|马赛克图:马赛克图同时看分组规模与组内占比

工具:Matplotlib。

三个渠道带来的用户数量不同,付费比例也不同。马赛克图用列宽编码渠道规模,用列内高度编码付费比例,两者相乘后的面积对应联合占比。

案例23 马赛克图同时看分组规模与组内占比

看图结果: 搜索列宽为 600/1500=40%,其中付费高度为 180/600=30%,对应矩形面积为 12% 的全部用户。

实现思路: 先计算渠道人数除以总人数,得到列宽;再计算付费人数除以本渠道人数,得到高度。Rectangle 的位置逐列累加,全部矩形面积的合计应为 1。

"""案例 23:马赛克图同时看分组规模与组内占比 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

from matplotlib.patches import Rectangle

rng, fig, ax = start(23, "")
df = pd.DataFrame(
    {
        "渠道": ["搜索", "社群", "视频"],
        "付费": [180, 160, 60],
        "未付费": [420, 240, 440],
    }
)
grand = df[["付费", "未付费"]].to_numpy().sum()
x = 0
area = 0
for row in df.itertuples():
    total = row.付费 + row.未付费
    width = total / grand
    y = 0
    for k, col in enumerate(["付费", "未付费"]):
        value = getattr(row, col)
        height = value / total
        ax.add_patch(
            Rectangle(
                (x, y),
                width,
                height,
                facecolor=COLORS[k],
                edgecolor="white",
                lw=3,
            )
        )
        ax.text(
            x + width / 2,
            y + height / 2,
            f"{col}{height:.0%}",
            ha="center",
            va="center",
            color="white",
        )
        y += height
        area += width * height
    ax.text(
        x + width / 2,
        -0.08,
        f"{row.渠道}({total}人)",
        ha="center",
    )
    x += width
assert np.isclose(area, 1)
ax.set(
    xlim=(0, 1), ylim=(-0.15, 1), xticks=[], ylabel="组内比例"
)
ax.grid(False)
finish(
    23,
    "马赛克图同时看分组规模与组内占比",
    fig,
    df,
    {"总样本": int(grand)},
)

别忽略: 这是两个分母配合工作的图。不能让列宽也表示付费比例,否则面积会把比例平方放大。需要精确比较转化率时,另画点图或条形图通常更轻松。

24|华夫图:华夫图把百分数还原成一百个格子

工具:Matplotlib。

一百项等权任务中,63 项完成、22 项进行中、15 项未开始。把一百个格子分别染色,读者可以把 63% 理解成每一百个里面有六十三个。

案例24 华夫图把百分数还原成一百个格子

看图结果: 前 63 格表示完成,随后 22 格表示进行中,最后 15 格表示未开始,全部 100 格都被分配。

实现思路: np.repeat 按数量生成类别编号,再用整除与取余把它放进 10 乘 10 的方阵。所有格子完全相同,所以颜色承担状态含义,面积承担比例含义。

"""案例 24:华夫图把百分数还原成一百个格子 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

from matplotlib.patches import Patch

rng, fig, ax = start(24, "", size=(6, 5))
df = pd.DataFrame(
    {
        "状态": ["完成", "进行中", "未开始"],
        "百分比": [63, 22, 15],
    }
)
classes = np.repeat(np.arange(3), df.百分比)
k = np.arange(100)
ax.scatter(
    k % 10,
    k // 10,
    c=[COLORS[i] for i in classes],
    s=145,
    marker="s",
)
ax.set(aspect="equal", xlim=(-1, 10), ylim=(-1, 10))
ax.invert_yaxis()
ax.axis("off")
ax.legend(
    handles=[
        Patch(color=COLORS[i], label=f"{r.状态}{r.百分比}%")
        for i, r in enumerate(df.itertuples())
    ],
    loc="upper center",
    bbox_to_anchor=(0.5, -0.02),
    ncol=3,
    fontsize=10,
)
assert len(classes) == 100
finish(24, "华夫图把百分数还原成一百个格子", fig, df)

别忽略: 当前数据刚好是整数百分比,没有舍入问题。面对 63.4% 这类值,要说明取整策略,或者增加格子数量;任务权重不同也不能直接一格一任务解释工作量完成率。

25|漏斗图:漏斗图先确定同一批用户与观察窗口

工具:Plotly。

假设跟踪同一批一万名访问用户,观察七天内有多少人依次注册、激活、付费。漏斗把每个阶段的剩余人数排在一起,帮助定位主要流失环节。

案例25 漏斗图先确定同一批用户与观察窗口

看图结果: 访问到付费是 780/10000=7.8%;激活到付费则是 780/2600=30%,两种比例不能互换。

实现思路: 每层宽度对应人数,图内同时写绝对值与相对最初访问用户的比例。数据要求非递增,代码会先检查这一点,但数量递减本身并不能证明已经正确去重。

"""案例 25:漏斗图先确定同一批用户与观察窗口 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

import plotly.graph_objects as go

df = pd.DataFrame(
    {
        "阶段": ["访问", "注册", "激活", "付费"],
        "人数": [10000, 4200, 2600, 780],
    }
)
assert (df.人数.diff().dropna() <= 0).all()
fig = go.Figure(
    go.Funnel(
        y=df.阶段,
        x=df.人数,
        texttemplate="%{value:,.0f}<br>%{percentInitial:.1%}",
        marker=dict(
            color=["#188568", "#389B81", "#66B19C", "#A0D3C4"]
        ),
    )
)
fig.update_layout(
    xaxis_title="人数:同一批访问用户,7天转化窗口"
)
finish(
    25,
    "漏斗图先确定同一批用户与观察窗口",
    fig,
    df,
    {
        "总转化率": 0.078,
        "注册到激活": 2600 / 4200,
        "激活到付费": 0.3,
    },
)

别忽略: 总转化率和相邻转化率是两个问题。这里最终付费占初始访问的 7.8%,而激活到付费是 30%;不要把当月注册人数与当月全部老客付费人数直接拼成同一漏斗。

交互版在资料包 outputs/25_v1.0.2.html,可在本地浏览器打开。

理解分布与密度 · 26—34

26|直方图:直方图,先看长尾再谈平均值

工具:Matplotlib。

学习会话时长通常右偏:大多数会话较短,少数特别长。我们用对数正态分布模拟 800 次会话,先观察形状,再决定用什么统计量概括。

案例26 直方图先看长尾再谈平均值

看图结果: 800 次会话的均值为 12.36 分钟,中位数为 10.27 分钟,长尾将均值向右拉。

实现思路: hist 把连续时长切成 24 个等宽区间,柱高表示区间内的会话数量。虚线标中位数,代码核对所有柱子的频数之和是否等于 800,确认没有把尾部悄悄丢掉。

"""案例 26:直方图,先看长尾再谈平均值 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

rng, fig, ax = start(26, "")
df = pd.DataFrame({"时长": rng.lognormal(2.3, 0.65, 800)})
counts, bins, _ = ax.hist(
    df.时长, bins=24, color=COLORS[0], edgecolor="white"
)
ax.axvline(
    df.时长.median(),
    color=COLORS[2],
    ls="--",
    label=f"中位数 {df.时长.median():.1f} 分钟",
)
assert counts.sum() == len(df)
ax.set(xlabel="单次学习时长(分钟)", ylabel="会话数(次)")
ax.legend()
finish(
    26,
    "直方图,先看长尾再谈平均值",
    fig,
    df,
    {
        "均值": df.时长.mean(),
        "中位数": df.时长.median(),
        "直方图计数": counts.sum(),
    },
)

别忽略: 直方图的横轴是连续区间,不是任意类别。分箱数量会改变外观,解释双峰或断层前应尝试不同箱宽;均值被长尾拉高时,不宜单独用均值描述典型会话。

27|核密度估计:核密度曲线,带宽决定细节尺度

工具:Seaborn。

一组成绩混合了两个水平的样本,我们画三条不同带宽的核密度曲线,看看双峰究竟有多稳定。它是在观测值周围平滑分配密度,不是在统计每个分数的人数。

案例27 核密度曲线带宽决定细节尺度

看图结果: 带宽倍率 0.5 保留更明显的局部双峰,倍率 2 将两个群体平滑得更接近一个宽峰。

实现思路: bw_adjust 越小,局部起伏越多;越大,曲线越平滑,也越容易合并两个峰。三条线来自同一份数据,改变的只有平滑参数,这样才能比较参数的影响。

"""案例 27:核密度曲线,带宽决定细节尺度 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

rng, fig, ax = start(27, "")
df = pd.DataFrame(
    {
        "成绩": np.r_[
            rng.normal(58, 7, 220), rng.normal(82, 5, 180)
        ]
    }
)
for bw, color in zip([0.5, 1, 2], COLORS):
    sns.kdeplot(
        data=df,
        x="成绩",
        bw_adjust=bw,
        cut=0,
        ax=ax,
        label=f"带宽倍率 {bw}",
        color=color,
    )
ax.set(xlabel="模拟测试成绩(分)", ylabel="概率密度(每分)")
ax.legend()
finish(27, "核密度曲线,带宽决定细节尺度", fig, df)

别忽略: 纵轴是概率密度,不是百分比,某个具体分数上的高度不等于该分数的概率。cut=0 只限制绘制范围,不等于边界偏差已被修正;有严格上下界的数据需要额外考虑估计方法。

28|箱线图与异常候选:箱线图保留异常点,不替数据做决定

工具:Seaborn。

三个版本各有 101 次请求,其中包含少量特别慢的观测。箱线图用中位数、四分位数和须线压缩分布,同时保留须线外的点。

案例28 箱线图保留异常点不替数据做决定

看图结果: 303 条请求全部保留,IQR 规则标出 7 条候选异常。A/B/C 各自的 800、720、690 毫秒观测仍可见。

实现思路: 代码按版本计算 Q1、Q3 和 IQR,将超出 1.5 倍 IQR 围栏的记录标为候选异常。绘图仍使用全部 303 行,并在结果文件里同时写明原始行数、候选数量和删除数量 0。

"""案例 28:箱线图保留异常点,不替数据做决定 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

rng, fig, ax = start(28, "")
df = pd.DataFrame(
    {
        "版本": np.repeat(["A", "B", "C"], 101),
        "响应": np.r_[
            rng.lognormal(5.2, 0.25, 100),
            800,
            rng.lognormal(5.0, 0.22, 100),
            720,
            rng.lognormal(4.9, 0.2, 100),
            690,
        ],
    }
)
sns.boxplot(
    data=df,
    x="版本",
    y="响应",
    ax=ax,
    color=COLORS[0],
    width=0.5,
    whis=1.5,
    showfliers=True,
)
flags = []
for name, g in df.groupby("版本"):
    q1, q3 = g.响应.quantile([0.25, 0.75])
    iqr = q3 - q1
    flags.extend(
        (
            (g.响应 < q1 - 1.5 * iqr)
            | (g.响应 > q3 + 1.5 * iqr)
        ).tolist()
    )
df["IQR候选异常"] = flags
ax.set(xlabel="版本", ylabel="请求响应时间(毫秒)")
finish(
    28,
    "箱线图保留异常点,不替数据做决定",
    fig,
    df,
    {
        "原始行数": len(df),
        "删除行数": 0,
        "候选异常数": sum(flags),
    },
)

别忽略: 候选异常可能是真实的慢请求,不能为让箱体显得整齐而反复删除。IQR 规则不是显著性检验,箱体分开也不能直接推出版本改动造成了性能改善。

29|小提琴图:小提琴图识别中位数背后的双峰

工具:Seaborn。

两班平均水平可能接近,但甲班集中在中间,乙班混合高分和低分。小提琴图把分布密度展开到左右两侧,能把这个差别显露出来。

案例29 小提琴图识别中位数背后的双峰

看图结果: 甲班主要集中在 70 分附近,乙班在 56 分和 84 分附近形成两个峰。一个中位数无法说明这两种结构的差别。

实现思路: 本例每班 240 人,内部虚线显示四分位数,cut=0 避免把图形尾部延伸到观测范围之外。density_norm='width' 把最大宽度对齐,方便比较形状。

"""案例 29:小提琴图识别中位数背后的双峰 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

rng, fig, ax = start(29, "")
df = pd.DataFrame(
    {
        "班级": np.repeat(["甲班", "乙班"], 240),
        "得分": np.r_[
            rng.normal(70, 8, 240),
            rng.normal(56, 4, 120),
            rng.normal(84, 4, 120),
        ],
    }
)
sns.violinplot(
    data=df,
    x="班级",
    y="得分",
    cut=0,
    inner="quart",
    density_norm="width",
    ax=ax,
    color=COLORS[1],
)
ax.set(ylabel="练习得分(分)")
finish(29, "小提琴图识别中位数背后的双峰", fig, df)

别忽略: 宽度归一化之后不能拿两个小提琴的面积比较人数,尤其在样本量不同时。密度形状受带宽影响,最好同时查看原始点、直方图或分位数,而不是只凭轮廓给人群贴标签。

30|蜂群图:蜂群图让小样本逐个露面

工具:Seaborn。

每组只有 35 位参与者时,直接展示每个人的耗时比只给均值更有信息。蜂群图把靠得太近的点横向错开,让重叠值也能被看到。

案例30 蜂群图让小样本逐个露面

看图结果: 三个组各 35 个点,新手整体耗时较高,但组间仍有重叠,不能将组名视为对个体耗时的确定判断。

实现思路: 纵向位置始终保留真实耗时,横向偏移只是避让布局,不是另一个变量。数据量不大、点尺寸适中时,这种排布能兼顾分组与个体差异。

"""案例 30:蜂群图让小样本逐个露面 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

rng, fig, ax = start(30, "")
df = pd.DataFrame(
    {
        "组别": np.repeat(["新手", "熟练", "资深"], 35),
        "耗时": np.r_[
            rng.normal(32, 5, 35),
            rng.normal(23, 4, 35),
            rng.normal(17, 3, 35),
        ],
    }
)
sns.swarmplot(
    data=df,
    x="组别",
    y="耗时",
    size=4.5,
    ax=ax,
    color=COLORS[0],
)
ax.set(
    ylabel="完成任务耗时(分钟)",
    xlabel="每点代表1位模拟参与者;每组35人",
)
finish(30, "蜂群图让小样本逐个露面", fig, df)

别忽略: 蜂群并不适合无限增加样本。点太多会出现无法排开的警告,此时应减小点、增加画布或改用透明散点与分布图;不能忽略警告后仍声称每个点都清楚可见。

31|人口金字塔式镜像图:镜像条形图比较两类用户年龄结构

工具:Matplotlib。

把新客放左、老客放右,可以逐年龄段比较两类用户的规模。这里借用人口金字塔的镜像结构,实际展示的是虚构产品用户,而不是人口统计数据。

案例31 镜像条形图比较两类用户年龄结构

看图结果: 新客 25–34 岁为 520 人,老客同段 390 人;35–44 岁则是老客 460 人多于新客 410 人。

实现思路: 左侧用负坐标完成镜像,但刻度格式化为绝对值,明确说明没有负人数。左右使用相同范围,年龄段按自然顺序排列,避免排序破坏年龄结构。

"""案例 31:镜像条形图比较两类用户年龄结构 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

from matplotlib.ticker import FuncFormatter

rng, fig, ax = start(31, "")
df = pd.DataFrame(
    {
        "年龄": ["18–24", "25–34", "35–44", "45–54", "55+"],
        "新客": [280, 520, 410, 260, 130],
        "老客": [150, 390, 460, 310, 190],
    }
)
ax.barh(df.年龄, -df.新客, label="新客")
ax.barh(df.年龄, df.老客, label="老客")
ax.axvline(0, color="#708078", lw=1)
ax.set_xlim(-600, 600)
ax.xaxis.set_major_formatter(
    FuncFormatter(lambda x, pos: f"{abs(x):.0f}")
)
ax.set(
    xlabel="人数(左侧为镜像显示,并非负人数)",
    ylabel="年龄(岁)",
)
ax.legend()
finish(31, "镜像条形图比较两类用户年龄结构", fig, df)

别忽略: 原始人数与组内比例是两种不同问题。本例比较人数,如果新客和老客总量差异很大而目标是比较年龄结构,应先分别归一化,并把两组分母写出来。

32|六边形分箱:六边形分箱,把遮挡变成密度

工具:Matplotlib。

七千个点叠在一起,最密集的区域可能只剩一团实心颜色。六边形分箱把平面划成小格,用颜色表示每格的观测数量,保留密集程度。

案例32 六边形分箱把遮挡变成密度

看图结果: 六边形计数之和为 7000,全部处理耗时大于零。密集区与少量高请求强度样本都被保留下来。

实现思路: gridsize 控制分箱精细度,mincnt=1 隐藏空格,颜色条给出实际计数。代码还核对所有格子计数之和等于 7000,确认每条观测都被计入。

"""案例 32:六边形分箱,把遮挡变成密度 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

rng, fig, ax = start(32, "")
x = rng.gamma(3, 12, 7000)
y = 25 + 2.1 * x + rng.gamma(3, 9, 7000)
assert (y > 0).all()
df = pd.DataFrame({"请求数": x, "耗时": y})
h = ax.hexbin(
    df.请求数, df.耗时, gridsize=32, mincnt=1, cmap="YlGnBu"
)
fig.colorbar(h, ax=ax, label="每格观测数(条)")
assert h.get_array().sum() == len(df)
ax.set(
    xlabel="每分钟请求数(连续模拟强度)",
    ylabel="处理耗时(毫秒)",
)
finish(
    32,
    "六边形分箱,把遮挡变成密度",
    fig,
    df,
    {"分箱计数": h.get_array().sum()},
)

别忽略: 格子越小,局部细节越多,单格计数也越不稳定。本例的横轴是连续模拟的请求强度,不能把它当作整数事件计数;若使用对数颜色刻度,也要在图例中明确标注。

33|二维密度等高线:二维密度等高线,寻找两群样本

工具:Seaborn。

投入与产出的散点看起来分成两个团,我们用二维核密度估计描出高密度区域,再叠加少量透明原始点帮助核对。

案例33 二维密度等高线寻找两群样本

看图结果: 高密度区域大致位于(2,3)与(5,6)附近,与两组模拟分布的中心相符;色阶没有被解释为统计显著性。

实现思路: 等高线连接密度水平相同的位置,填色让不同密度层级更容易辨认。Seaborn 的 levels 参数在这里用于设置等概率质量层级,不能把某条轮廓直接读成回归置信边界。

"""案例 33:二维密度等高线,寻找两群样本 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

rng, fig, ax = start(33, "")
a = rng.multivariate_normal(
    [2, 3], [[0.6, 0.3], [0.3, 0.6]], 450
)
b = rng.multivariate_normal(
    [5, 6], [[0.6, -0.2], [-0.2, 0.6]], 350
)
df = pd.DataFrame(np.vstack([a, b]), columns=["投入", "产出"])
sns.kdeplot(
    data=df,
    x="投入",
    y="产出",
    fill=True,
    levels=7,
    thresh=0.05,
    cmap="YlGnBu",
    ax=ax,
)
ax.scatter(df.投入, df.产出, s=3, c="#243E35", alpha=0.13)
ax.set(xlabel="标准化投入指数", ylabel="标准化产出指数")
finish(33, "二维密度等高线,寻找两群样本", fig, df)

别忽略: 两个密度峰不等于已经识别出两个真实客户群。带宽、尺度和抽样方式都会影响形状,要建立分群结论仍需要业务字段、稳定性检查及独立样本。

34|三维曲面:三维曲面只用于真实的第三个数值维度

工具:Plotly。

模拟一个二维平面上的温升场,每个 X、Y 位置都有对应的温升值。第三个轴承载真实数值,因此三维曲面在这个场景里有明确用途。

案例34 三维曲面只用于真实的第三个数值维度

看图结果: 曲面包含两个热源,主峰靠近(1,0.5),另一个峰靠近(-1.4,-1)。高度与颜色都表示同一个温升变量。

实现思路: meshgrid 生成规则网格,两个高斯形状的热源叠加成温升,Surface 把每个网格位置连接成曲面。交互文件可以旋转,PNG 保留固定视角作为文章插图。

"""案例 34:三维曲面只用于真实的第三个数值维度 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

import plotly.graph_objects as go

x = np.linspace(-3, 3, 55)
y = np.linspace(-3, 3, 55)
X, Y = np.meshgrid(x, y)
Z = 80 * np.exp(
    -((X - 1) ** 2 + (Y - 0.5) ** 2) / 2
) + 40 * np.exp(-((X + 1.4) ** 2 + (Y + 1) ** 2))
df = pd.DataFrame(
    {"X": X.ravel(), "Y": Y.ravel(), "温升": Z.ravel()}
)
fig = go.Figure(
    go.Surface(
        x=X,
        y=Y,
        z=Z,
        colorscale="Viridis",
        colorbar=dict(title="温升"),
    )
)
fig.update_layout(
    scene=dict(
        xaxis_title="X(米)",
        yaxis_title="Y(米)",
        zaxis_title="温升(℃)",
        camera=dict(eye=dict(x=1.6, y=-1.7, z=1.2)),
    )
)
finish(34, "三维曲面只用于真实的第三个数值维度", fig, df)

别忽略: 这是虚构场景的解析模拟,没有真实传感器或地理边界。三维透视会遮挡部分区域,比较精确位置值应配合悬浮提示或二维等高线;普通柱状图不需要额外加一层立体透视。

交互版在资料包 outputs/34_v1.0.2.html,可在本地浏览器打开。

检查变量关联 · 35—39

35|散点与线性拟合:散点与回归线,不把相关写成因果

工具:Matplotlib + SciPy。

练习时长与测试成绩是否一起变化?我们构造 180 个观测,用散点显示每条记录,再用最小二乘直线概括线性关系。

案例35 散点与回归线不把相关写成因果

看图结果: 这批模拟样本的 Pearson r 为 0.809,拟合斜率约 3.87 分/小时,只描述当前生成数据的线性关联。

实现思路: linregress 返回斜率、截距和相关系数,图例报告 r。拟合线只覆盖已有数据范围,不把直线随意延长到零小时以下或极高时长处。

"""案例 35:散点与回归线,不把相关写成因果 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

from scipy.stats import linregress

rng, fig, ax = start(35, "")
x = rng.uniform(1, 12, 180)
y = 22 + 4.2 * x + rng.normal(0, 9, 180)
df = pd.DataFrame({"练习小时": x, "成绩": y})
fit = linregress(x, y)
ax.scatter(x, y, s=25, alpha=0.55)
xx = np.array([x.min(), x.max()])
ax.plot(
    xx,
    fit.intercept + fit.slope * xx,
    color=COLORS[2],
    label=f"线性拟合,r={fit.rvalue:.2f}",
)
ax.set(
    xlabel="每周练习时长(小时)", ylabel="模拟测验成绩(分)"
)
ax.legend()
finish(
    35,
    "散点与回归线,不把相关写成因果",
    fig,
    df,
    {
        "Pearson_r": fit.rvalue,
        "斜率": fit.slope,
        "说明": "生成机制预设正相关,非实验因果结论",
    },
)

别忽略: 这个正相关是模拟公式主动设置的,不是任何教育效果证据。真实分析还要检查非线性、异常观测、共同原因和样本选择,r 较高也不意味着可以直接宣称练习造成相同幅度的成绩提升。

36|多变量气泡图:气泡图同时看成本、质量和使用规模

工具:Plotly。

比较十二个方案时,质量与成本决定二维位置,用户数决定气泡面积,类型决定颜色。这样可以同时观察高质量低成本区域,以及这些方案当前的使用规模。

案例36 气泡图同时看成本质量和使用规模

看图结果: 左上角表示相对低成本、高评分的方案,大气泡表示更多用户。三种视觉含义相互独立,不能只追着最大气泡选。

实现思路: Plotly 按 size 字段生成面积映射,代码显式保留 sizemode='area'。方案名称放进悬浮提示,静态图片避免把十二个标签全部压在气泡上。

"""案例 36:气泡图同时看成本、质量和使用规模 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

import plotly.express as px

rng = np.random.default_rng(20260949)
df = pd.DataFrame(
    {
        "方案": [f"方案{i}" for i in range(1, 13)],
        "月成本": rng.uniform(1, 12, 12),
        "质量": rng.uniform(65, 96, 12),
        "用户数": rng.integers(100, 3000, 12),
        "类型": np.repeat(["轻量", "通用", "专业"], 4),
    }
)
fig = px.scatter(
    df,
    x="月成本",
    y="质量",
    size="用户数",
    color="类型",
    hover_name="方案",
    size_max=65,
    labels={
        "月成本": "月成本(万元)",
        "质量": "质量评分(分)",
    },
    color_discrete_sequence=COLORS,
)
fig.update_traces(marker_sizemode="area", marker_opacity=0.72)
finish(36, "气泡图同时看成本、质量和使用规模", fig, df)

别忽略: 大气泡代表用户多,不代表质量高,也不代表更适合当前需求。面积仍是较难精确比较的通道,具体人数以交互提示和 CSV 为准,颜色类别也不构成优劣排序。

交互版在资料包 outputs/36_v1.0.2.html,可在本地浏览器打开。

37|散点矩阵:散点矩阵快速筛查多变量关系

工具:Seaborn。

有投入、产出、等待、经验四个变量时,可以先用散点矩阵扫描两两关系。对角线显示各变量自身分布,非对角线显示两个变量的联合观测。

案例37 散点矩阵快速筛查多变量关系

看图结果: 投入与产出呈正相关,投入与等待呈负相关;经验在这些模拟变量中没有被设置明显的关联。

实现思路: corner=True 只保留下三角,减少镜像重复;对角线采用直方图,避免在第一次探索时同时引入过多平滑参数。所有子图来自同一批 160 条完整记录。

"""案例 37:散点矩阵快速筛查多变量关系 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

rng = np.random.default_rng(20260950)
n = 160
x = rng.normal(50, 10, n)
df = pd.DataFrame(
    {
        "投入": x,
        "产出": 1.4 * x + rng.normal(0, 8, n),
        "等待": 90 - 0.7 * x + rng.normal(0, 7, n),
        "经验": rng.uniform(1, 10, n),
    }
)
g = sns.pairplot(
    df,
    corner=True,
    diag_kind="hist",
    height=2.1,
    plot_kws={"s": 12, "alpha": 0.5, "color": COLORS[0]},
    diag_kws={"bins": 15, "color": COLORS[1]},
)
finish(37, "散点矩阵快速筛查多变量关系", g.fig, df)

别忽略: 矩阵适合找下一步要研究的线索,不适合把每个格子都写成结论。变量一多,手机上会很拥挤,所以正文提供可放大的原图,真正报告时应挑出最关键的两三对关系单独画。

38|相关系数热力图:相关热力图保留正负方向

工具:Seaborn。

在散点筛查之后,用一张矩阵汇总线性相关程度。颜色以零为中点,正相关与负相关分居两侧,每格再写出具体 Pearson 相关系数。

案例38 相关热力图保留正负方向

看图结果: 流量与收入的 r 约 0.86,与等待的 r 约 -0.78。固定 -1 到 1 的色阶保留了方向与强度。

实现思路: 固定 vmin=-1、vmax=1,保证不同图之间的颜色可比较;不能每张图根据局部极值拉满色阶,让很弱的相关看起来很强。代码检查矩阵对称、对角线为 1。

"""案例 38:相关热力图保留正负方向 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

rng, fig, ax = start(38, "", size=(7, 5.5))
x = rng.normal(size=240)
df = pd.DataFrame(
    {
        "流量": x,
        "收入": 0.8 * x + rng.normal(0, 0.5, 240),
        "等待": -0.7 * x + rng.normal(0, 0.6, 240),
        "评分": rng.normal(size=240),
    }
)
corr = df.corr(method="pearson")
sns.heatmap(
    corr,
    ax=ax,
    annot=True,
    fmt=".2f",
    vmin=-1,
    vmax=1,
    center=0,
    cmap="vlag",
    square=True,
    cbar_kws={"label": "Pearson r"},
)
assert np.allclose(corr, corr.T) and np.allclose(
    np.diag(corr), 1
)
finish(
    38,
    "相关热力图保留正负方向",
    fig,
    {"raw": df, "correlation": corr.reset_index()},
)

别忽略: 相关接近零不代表不存在关系,弯曲关系就可能被线性相关遗漏。真实数据还要说明缺失值处理、成对样本数和重复测量,不能只给一张彩色矩阵就省略数据口径。

39|分组揭示辛普森悖论:辛普森悖论,合并后关系为什么反转

工具:Matplotlib。

同一份数据,合在一起呈负相关,拆开后却都呈正相关,这并不矛盾。本例让困难任务投入更久但整体得分更低,同时在每种难度内部保留投入越多得分越高的趋势。

案例39 辛普森悖论合并后关系为什么反转

看图结果: 合并 r=-0.895;简单任务 r=0.907,困难任务 r=0.853。关系反转已经在实际输出中核对。

实现思路: 左图把两组混在一起,右图按任务难度分别着色并拟合。代码检查合并相关系数小于零、两个分组相关系数都大于零,确保反转确实发生,而不是只写了一个悖论标题。

"""案例 39:辛普森悖论,合并后关系为什么反转 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

rng, fig, axes = start(39, "", size=(10, 4.8), ncols=2)
x1 = rng.uniform(1, 4, 100)
x2 = rng.uniform(6, 9, 100)
df = pd.DataFrame(
    {
        "投入": np.r_[x1, x2],
        "得分": np.r_[
            75 + 2 * x1 + rng.normal(0, 1, 100),
            42 + 2 * x2 + rng.normal(0, 1, 100),
        ],
        "任务": np.repeat(["简单", "困难"], 100),
    }
)
rs = {}
for ax, split in zip(axes, [False, True]):
    groups = df.groupby("任务") if split else [("合并", df)]
    for name, g in groups:
        ax.scatter(g.投入, g.得分, s=15, alpha=0.6, label=name)
        m, b = np.polyfit(g.投入, g.得分, 1)
        xx = np.array([g.投入.min(), g.投入.max()])
        ax.plot(xx, m * xx + b)
        rs[name] = g.投入.corr(g.得分)
    ax.set(
        xlabel="投入时间(小时)",
        ylabel="得分(分)",
        title="按难度拆分" if split else "直接合并",
        ylim=(45, 88),
    )
    ax.legend()
assert rs["合并"] < 0 and rs["简单"] > 0 and rs["困难"] > 0
finish(39, "辛普森悖论,合并后关系为什么反转", fig, df, rs)

别忽略: 关键是组间差异和组内关系混在了一起。看到关系反转时,应追查分组结构与样本权重;分组本身也不自动解决因果识别问题。

整理层级与流向 · 40—45

40|旭日图:旭日图从业务线逐层看到产品

工具:Plotly。

收入先分为学习和工具两条业务线,再分到六个产品。旭日图把层级放在同心环中,读者从内向外读到更细的产品。

案例40 旭日图从业务线逐层看到产品

看图结果: 学习与工具业务各 100 万元,六个产品叶子节点合计 200 万元。父级金额来自叶子汇总,没有重复录入。

实现思路: 原始表只存叶子产品收入,path 定义业务到产品的层级,Plotly 据此汇总父级。这样可以避免把父级总额和子级金额放在同一层重复相加。

"""案例 40:旭日图从业务线逐层看到产品 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

import plotly.express as px

df = pd.DataFrame(
    {
        "业务": [
            "学习",
            "学习",
            "学习",
            "工具",
            "工具",
            "工具",
        ],
        "产品": [
            "Python",
            "SQL",
            "可视化",
            "清洗",
            "报表",
            "协作",
        ],
        "收入": [38, 27, 35, 42, 33, 25],
    }
)
fig = px.sunburst(
    df,
    path=["业务", "产品"],
    values="收入",
    color="业务",
    color_discrete_sequence=COLORS,
)
fig.update_traces(
    textinfo="label+value", insidetextorientation="horizontal"
)
finish(
    40,
    "旭日图从业务线逐层看到产品",
    fig,
    df,
    {"叶子合计": int(df.收入.sum())},
)

别忽略: 每个产品必须有明确上级,重名产品最好使用唯一编号。扇区过小时,不要强塞所有标签;交互版适合展开查看,静态图更适合展示层级较浅、类别较少的结构。

交互版在资料包 outputs/40_v1.0.2.html,可在本地浏览器打开。

41|矩形树图:矩形树图展示空间占用的长尾

工具:Matplotlib + squarify。

服务器空间由日志、图片、视频、模型等目录占用。我们把每个目录画成一个面积与容量成比例的矩形,整个画布代表已列出的目录总量。

案例41 矩形树图展示空间占用的长尾

看图结果: 目录合计 1100 GB ,日志 360 GB 约占 32.7%。图片、视频与日志合计 780 GB,是主要空间占用来源。

实现思路: squarify 接收从大到小的正数容量,计算较容易阅读的矩形布局。这里展示单层目录,名称与 GB 数直接写进矩形,不假装存在未建模的多层文件树。

"""案例 41:矩形树图展示空间占用的长尾 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

import squarify

rng, fig, ax = start(41, "")
df = pd.DataFrame(
    {
        "目录": [
            "日志",
            "图片",
            "视频",
            "模型",
            "备份",
            "缓存",
            "文档",
            "其他",
        ],
        "容量": [360, 240, 180, 120, 80, 50, 40, 30],
    }
).sort_values("容量", ascending=False)
squarify.plot(
    sizes=df.容量,
    label=[f"{r.目录}\n{r.容量} GB" for r in df.itertuples()],
    color=[COLORS[i % 6] for i in range(len(df))],
    ax=ax,
    pad=True,
    text_kwargs={"color": "white", "fontsize": 12},
)
ax.axis("off")
finish(
    41,
    "矩形树图展示空间占用的长尾",
    fig,
    df,
    {
        "总容量GB": int(df.容量.sum()),
        "最大占比": float(df.容量.max() / df.容量.sum()),
    },
)

别忽略: 颜色只帮助区分目录,不再编码另一个排序。数据必须是非负可加的量,软链接重复统计、父目录与子目录同时求和,都可能让磁盘占用被重复计算。

42|组织树:树图表达唯一上级的组织关系

工具:NetworkX + Matplotlib。

负责人下面分产品、技术、运营,每组再连接两个岗位。树图表达的是从上级到下级的关系,位置只是组织层次的排布。

案例42 树图表达唯一上级的组织关系

看图结果: 全图有 10 个节点、9 条上级到下级的边,只有负责人没有父节点,满足一棵有根树的结构。

实现思路: 用有向边建立 DiGraph,并检查它是否是有根有向树。布局显式安排每层坐标,节点间距和字体可以独立调整,比默认力导向布局更适合阅读组织结构。

"""案例 42:树图表达唯一上级的组织关系 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

import networkx as nx

rng, fig, ax = start(42, "", size=(9, 5))
edges = [
    ("负责人", "产品组"),
    ("负责人", "技术组"),
    ("负责人", "运营组"),
    ("产品组", "研究"),
    ("产品组", "设计"),
    ("技术组", "前端"),
    ("技术组", "后端"),
    ("运营组", "内容"),
    ("运营组", "社群"),
]
df = pd.DataFrame(edges, columns=["上级", "下级"])
G = nx.DiGraph(edges)
pos = {
    "负责人": (0, 0),
    "产品组": (-3, -1),
    "技术组": (0, -1),
    "运营组": (3, -1),
    "研究": (-4, -2),
    "设计": (-2.4, -2),
    "前端": (-0.8, -2),
    "后端": (0.8, -2),
    "内容": (2.4, -2),
    "社群": (4, -2),
}
assert nx.is_arborescence(G)
nx.draw_networkx(
    G,
    pos,
    ax=ax,
    node_color="#D6ECE3",
    node_size=2100,
    font_family=FONT_NAME,
    font_size=11,
    arrows=True,
    arrowstyle="-|>",
    arrowsize=16,
    edge_color="#729B89",
)
ax.margins(0.18)
ax.axis("off")
finish(42, "树图表达唯一上级的组织关系", fig, df)

别忽略: 组织树隐含每个非根节点只有一个父节点。矩阵式汇报、跨部门协作、兼职关系不能不加说明地硬塞进一棵树,应改用一般有向网络或分别表达正式与协作关系。

43|关系网络:关系网络寻找跨团队的连接点

工具:NetworkX + Matplotlib。

两个团队内部紧密协作,通过一个协调节点与第三个小组连接。网络图把成员变成点、协作变成边,观察桥梁位置比把关系塞进名单更清楚。

案例43 关系网络寻找跨团队的连接点

看图结果: 协调节点位于多个团队之间,连接结构使其中介中心性较高;这项图指标不等于对该成员绩效的评价。

实现思路: spring_layout 使用固定种子,让同一数据的布局可复现。节点面积随中介中心性增加,突出位于较多最短路径上的成员;原始边和节点指标分别导出。

"""案例 43:关系网络寻找跨团队的连接点 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

import networkx as nx

rng, fig, ax = start(43, "", size=(8, 6))
edges = [
    ("A1", "A2"),
    ("A2", "A3"),
    ("A3", "A1"),
    ("B1", "B2"),
    ("B2", "B3"),
    ("B3", "B1"),
    ("A2", "协调"),
    ("协调", "B2"),
    ("协调", "C1"),
    ("C1", "C2"),
]
df = pd.DataFrame(edges, columns=["成员1", "成员2"])
G = nx.Graph(edges)
centrality = nx.betweenness_centrality(G)
pos = nx.spring_layout(G, seed=43)
nx.draw_networkx(
    G,
    pos,
    ax=ax,
    node_size=[600 + 3500 * centrality[n] for n in G],
    node_color=[
        COLORS[0] if n == "协调" else "#A9CBE2" for n in G
    ],
    font_family=FONT_NAME,
    font_size=11,
    edge_color="#BAC8C0",
)
ax.axis("off")
finish(
    43,
    "关系网络寻找跨团队的连接点",
    fig,
    {
        "edges": df,
        "nodes": pd.DataFrame(
            {
                "成员": list(G),
                "中介中心性": list(centrality.values()),
            }
        ),
    },
)

别忽略: 中介中心性描述图上的结构,不等同于工作绩效、正式权力或实际控制权。边在这里是无向的协作存在关系,没有表示频次,不能从线长推断协作距离。

44|有向邻接矩阵:邻接矩阵还原谁向谁发起协作

工具:Seaborn。

产品、研发、测试、运营、客服之间互相发起协作请求。将发起团队排在行、接收团队排在列,每个格子记录从行到列的次数。

案例44 邻接矩阵还原谁向谁发起协作

看图结果: 矩阵共记录 169 次跨团队请求;产品到研发为 7 次,研发到产品为 10 次,方向差异得到保留。

实现思路: 矩阵不强制对称,因为产品向研发请求 20 次,不意味着研发也向产品请求 20 次。对角线设为零,表示本例只研究跨团队请求,颜色与格内数字共同表达计数。

"""案例 44:邻接矩阵还原谁向谁发起协作 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

rng, fig, ax = start(44, "")
names = ["产品", "研发", "测试", "运营", "客服"]
matrix = rng.integers(0, 26, (5, 5))
np.fill_diagonal(matrix, 0)
df = pd.DataFrame(matrix, columns=names, index=names)
sns.heatmap(
    df,
    annot=True,
    fmt="d",
    cmap="YlGnBu",
    ax=ax,
    cbar_kws={"label": "协作请求数(次)"},
    square=True,
)
ax.set(xlabel="接收团队(列)", ylabel="发起团队(行)")
finish(
    44,
    "邻接矩阵还原谁向谁发起协作",
    fig,
    df.rename_axis("发起团队").reset_index(),
    {"总请求": int(matrix.sum()), "有向": True},
)

别忽略: 这不是相关系数热力图,不存在 -1 到 1 的范围。箭头方向已经由行列承担,必须把方向写在轴标题上,避免读者把需求来源与承接对象读反。

45|桑基图:桑基图,流入流出必须对得上

工具:Plotly。

一千名用户从搜索和社群进入,部分注册,注册者再分成付费与未付费。桑基图用连线宽度表示流量,可以把渠道来源与后续去向放进同一条路径。

案例45 桑基图流入流出必须对得上

看图结果: 搜索 600 人、社群 400 人;640 人注册后,192 人付费、448 人未付费,另有 360 人在注册前结束路径。

实现思路: 边表使用 source、target 和 value,节点表单独保存编号与名称。注册节点的流入为 360 加 280,流出为 192 加 448,两侧都等于 640,代码会强制核对。

"""案例 45:桑基图,流入流出必须对得上 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

import plotly.graph_objects as go

labels = ["搜索", "社群", "注册", "未注册", "付费", "未付费"]
df = pd.DataFrame(
    {
        "source": [0, 0, 1, 1, 2, 2],
        "target": [2, 3, 2, 3, 4, 5],
        "value": [360, 240, 280, 120, 192, 448],
    }
)
assert (
    df.loc[df.target == 2, "value"].sum()
    == df.loc[df.source == 2, "value"].sum()
)
assert df.loc[df.source.isin([0, 1]), "value"].sum() == 1000
fig = go.Figure(
    go.Sankey(
        node=dict(
            label=labels, pad=25, thickness=22, color=COLORS
        ),
        link=dict(
            source=df.source,
            target=df.target,
            value=df.value,
            color="rgba(24,133,104,0.25)",
        ),
    )
)
finish(
    45,
    "桑基图,流入流出必须对得上",
    fig,
    {
        "edges": df,
        "nodes": pd.DataFrame(
            {"id": range(6), "label": labels}
        ),
    },
    {
        "源头人数": 1000,
        "注册人数": 640,
        "最终付费": 192,
        "守恒": True,
    },
)

别忽略: 未注册与未付费是终点,要作为独立去向保留下来。不能把流失用户直接删掉后仍声称流量守恒;如果每步允许同一个人重复进入,需要明确这里计的是事件而不是独立人数。

交互版在资料包 outputs/45_v1.0.2.html,可在本地浏览器打开。

文本、缺测与不确定性 · 46—50

46|中文词云:词云适合发现主题,不适合精确排名

工具:wordcloud + Matplotlib。

假设一批课程反馈已经分词,我们想快速了解大家常提什么。本例从预设词表模拟 700 个词,统计词频后生成中文词云,不读取他人的评论或歌词。

案例46 中文词云适合发现主题不适合精确排名

看图结果: 700 个模拟词中,图表出现 96 次。精确排序以导出的词频表为准。

实现思路: Counter 负责计数,WordCloud 通过 generate_from_frequencies 接收词频,指定中文字体和固定随机种子。提供的词序列 CSV 让读者能回溯每个频次的来源。

"""案例 46:词云适合发现主题,不适合精确排名 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

from collections import Counter
from wordcloud import WordCloud

rng, fig, ax = start(46, "")
words = [
    "代码",
    "案例",
    "图表",
    "清晰",
    "数据",
    "实践",
    "讲解",
    "复现",
    "学习",
    "工具",
    "好用",
    "步骤",
]
weights = np.array(
    [14, 13, 12, 11, 10, 9, 8, 7, 6, 4, 3, 3], dtype=float
)
tokens = rng.choice(words, 700, p=weights / weights.sum())
counts = Counter(tokens)
df = pd.DataFrame(
    counts.items(), columns=["词", "频次"]
).sort_values("频次", ascending=False)
wc = WordCloud(
    font_path=FONT,
    width=1200,
    height=650,
    background_color="white",
    colormap="viridis",
    random_state=46,
    collocations=False,
).generate_from_frequencies(counts)
ax.imshow(wc)
ax.axis("off")
finish(
    46,
    "词云适合发现主题,不适合精确排名",
    fig,
    {
        "tokens": pd.DataFrame({"分词结果": tokens}),
        "frequency": df,
    },
    {"总词数": len(tokens)},
)

别忽略: 词云的字体大小还受到排布与词长影响,不适合精确比较第一名与第二名。真实中文文本应先完成分词、停用词、同义词和大小写规则处理;词出现得多不自动等于用户态度正面。

47|归一化词频剖面:比较文本偏好,先消除篇幅差异

工具:Matplotlib。

三份语料分别有 2000、8000、5000 个词,直接比较代码一词出现多少次,会把篇幅差异当成内容偏好。我们先把每个关键词的次数转换为每千词出现次数。

案例47 归一化词频剖面比较文本偏好先消除篇幅差异

看图结果: 入门语料里"步骤"出现 90 次/千词,实战语料里"代码"出现 100 次/千词。进阶语料更长,但归一化后不再因篇幅直接占优。

实现思路: 分母是各自语料的全部有效词数,不是当前四个关键词的合计。这样四个关键词的值不必相加等于 1000,仍然能在统一尺度上比较相对使用强度。

"""案例 47:比较文本偏好,先消除篇幅差异 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

rng, fig, ax = start(47, "")
df = pd.DataFrame(
    {
        "语料": ["入门反馈", "进阶反馈", "实战反馈"],
        "总词数": [2000, 8000, 5000],
        "代码": [120, 560, 500],
        "原理": [40, 640, 250],
        "步骤": [180, 240, 200],
        "报错": [100, 160, 400],
    }
)
words = ["代码", "原理", "步骤", "报错"]
norm = df[words].div(df.总词数, axis=0) * 1000
for i, row in norm.iterrows():
    ax.plot(words, row, "o-", label=df.语料.iloc[i], lw=2)
ax.set(
    ylabel="每千词出现次数",
    xlabel="关键词(位置只用于排布,无连续距离含义)",
    ylim=(0, 110),
)
ax.legend()
finish(
    47,
    "比较文本偏好,先消除篇幅差异",
    fig,
    {"counts": df, "per_1000": norm.assign(语料=df.语料)},
)

别忽略: 折线只是帮助识别词频轮廓,词与词之间没有连续距离,不能解读斜率。真实文本比较还应检查文体、抽样时期与分词规则一致性,不能用几条高频词直接概括一个作者的全部风格。

48|情绪与长度四象限:四象限图定位长篇负向反馈

工具:Matplotlib。

反馈很多时,可以先把长篇负向反馈送入人工复核队列。本例横轴是字数,纵轴是直接模拟的情绪指数,200 字和零分是人为设定的两条参考线。

案例48 四象限图定位长篇负向反馈

看图结果: 按 200 字与零分两条规则,51 条记录进入优先复核区。这是规则筛选数量,不是投诉识别准确率。

实现思路: 右下区域同时满足字数不少于 200 和情绪指数小于零,代码将这些记录标记并导出。散点保留全部样本,红色只表示需要优先复核,不表示系统已经判定投诉成立。

"""案例 48:四象限图定位长篇负向反馈 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

rng, fig, ax = start(48, "")
df = pd.DataFrame(
    {
        "反馈编号": range(1, 181),
        "字数": rng.integers(20, 450, 180),
        "情绪指数": np.clip(rng.normal(0.1, 0.4, 180), -1, 1),
    }
)
threshold = 200
df["优先复核"] = (df.字数 >= threshold) & (df.情绪指数 < 0)
ax.axvspan(
    threshold,
    470,
    ymin=0,
    ymax=0.5,
    color=COLORS[4],
    alpha=0.08,
)
ax.scatter(
    df.字数,
    df.情绪指数,
    c=np.where(df.优先复核, COLORS[4], COLORS[1]),
    s=22,
    alpha=0.7,
)
ax.axvline(threshold, color="#AAB6B0", ls="--")
ax.axhline(0, color="#AAB6B0", ls="--")
ax.set(
    xlabel="反馈字数(字)",
    ylabel="模拟情绪指数(-1至1)",
    xlim=(0, 470),
    ylim=(-1, 1),
)
ax.text(
    300,
    -0.88,
    "长篇负向:优先人工复核",
    fontsize=10,
    color=COLORS[4],
)
finish(
    48,
    "四象限图定位长篇负向反馈",
    fig,
    df,
    {
        "人工设定长度阈值": threshold,
        "优先复核条数": int(df.优先复核.sum()),
        "指数来源": "直接模拟,未调用情感模型",
    },
)

别忽略: 这里没有调用情感模型,也没有宣称具备真实识别准确率。换成模型分数后,需要检查标注集、阈值和讽刺表达等错误;分数为负也不意味着反馈没有建设性。

49|覆盖率伴随的月均趋势:日数据汇总为月均值,缺测不能当零

工具:Matplotlib + pandas。

一年日数据中,六月整月缺失,九月前 16 天缺失。如果先填零再求月均,就会制造一场并不存在的骤降;如果只画有值月份,还会把缺口悄悄连起来。

案例49 覆盖率伴随的月均趋势日数据汇总为月均值缺测不能当零

看图结果: 六月覆盖率 0%,九月为 14/30≈46.7%,两个月都不显示月均值;其余月份保留完整观测。

实现思路: 先按月分别计算均值、有效天数和预期天数,再得到覆盖率。本例约定低于 80% 不展示月均值,上图保留断点,下图显示覆盖率,让读者知道为什么有些月份看不到趋势点。

"""案例 49:日数据汇总为月均值,缺测不能当零 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

rng, fig, axes = start(
    49,
    "",
    size=(9, 6),
    nrows=2,
    gridspec_kw={"height_ratios": [2, 1]},
)
dates = pd.date_range("2025-01-01", "2025-12-31")
n = len(dates)
df = pd.DataFrame(
    {
        "日期": dates,
        "读数": 50
        + 18 * np.cos(np.arange(n) / 365 * 2 * np.pi)
        + rng.normal(0, 6, n),
    }
)
df.loc[df.日期.dt.month == 6, "读数"] = np.nan
df.loc[
    (df.日期.dt.month == 9) & (df.日期.dt.day < 17), "读数"
] = np.nan
monthly = (
    df.set_index("日期")
    .resample("MS")
    .读数.agg(["mean", "count", "size"])
)
monthly["coverage"] = monthly["count"] / monthly["size"]
monthly["shown"] = monthly["mean"].where(
    monthly.coverage >= 0.8
)
assert monthly.loc["2025-06-01", "count"] == 0 and pd.isna(
    monthly.loc["2025-06-01", "shown"]
)
axes[0].plot(monthly.index, monthly.shown, "o-", lw=2)
axes[0].set_ylabel("传感器月均读数(单位)")
axes[1].bar(
    monthly.index,
    monthly.coverage * 100,
    width=20,
    color=np.where(
        monthly.coverage >= 0.8, COLORS[0], COLORS[4]
    ),
)
axes[1].axhline(80, ls="--", color="#777777")
axes[1].set(
    ylabel="覆盖率(%)",
    ylim=(0, 110),
    xlabel="月份;低于80%不展示月均值",
)
fig.autofmt_xdate()
finish(
    49,
    "日数据汇总为月均值,缺测不能当零",
    fig,
    {"daily": df, "monthly": monthly.reset_index()},
)

别忽略: 80% 是演示阈值,不是通用统计标准。日记录不等于全天均值,如果原始数据按小时采样,还需要核对每天覆盖情况;缺测若有系统性,即使覆盖率高,均值也可能有偏。

50|均值及95%置信区间:均值之外,补上不确定性的范围

工具:Matplotlib + SciPy。

四组各抽 40 个独立样本,均值点旁边再加上 95% t 置信区间,帮助读者区分估计位置与估计精度。横向误差棒比高高的均值柱更节省视觉空间。

案例50 均值及95%置信区间补上不确定性的范围

看图结果: 误差棒围绕各组均值展开,较大的样本波动会带来更宽区间。每组都是 40 个独立模拟样本,区间对象为总体均值。

实现思路: 区间半宽为 t 分位数乘以样本标准差再除以样本量平方根。代码使用自由度 n-1,明确区间针对总体均值,并用 SciPy 的 sem 计算结果交叉核对。

"""案例 50:均值之外,补上不确定性的范围 | v1.0.2 | 才哥AGI | 全部为模拟数据。"""

from common_v1_0_2 import *

from scipy.stats import t, sem

rng, fig, ax = start(50, "")
df = pd.DataFrame(
    {
        "组别": np.repeat(["甲", "乙", "丙", "丁"], 40),
        "耗时": np.r_[
            rng.normal(20, 4, 40),
            rng.normal(22, 7, 40),
            rng.normal(19, 3, 40),
            rng.normal(24, 5, 40),
        ],
    }
)
summary = df.groupby("组别", sort=False).耗时.agg(
    ["mean", "std", "count"]
)
summary["halfwidth"] = (
    t.ppf(0.975, summary["count"] - 1)
    * summary["std"]
    / np.sqrt(summary["count"])
)
for name, g in df.groupby("组别", sort=False):
    assert np.isclose(
        summary.loc[name, "halfwidth"],
        t.ppf(0.975, len(g) - 1) * sem(g.耗时),
    )
ax.errorbar(
    summary["mean"],
    np.arange(4),
    xerr=summary.halfwidth,
    fmt="o",
    capsize=6,
    ms=8,
    color=COLORS[0],
)
ax.set(
    yticks=np.arange(4),
    yticklabels=summary.index,
    xlabel="任务耗时均值及95% t置信区间(分钟)",
)
finish(
    50,
    "均值之外,补上不确定性的范围",
    fig,
    {"raw": df, "summary": summary.reset_index()},
    {
        "置信水平": 0.95,
        "区间对象": "总体均值",
        "每组独立样本": 40,
        "重复测量": False,
    },
)

别忽略: 95% 描述的是在相同抽样机制下反复构造区间的长期覆盖率,不是 95% 的个体落在这根误差棒内。区间重叠也不能直接替代组间差异检验,重复测量或强偏态数据需要调整估计方法。

三、把示例换成自己的数据

第一步:保留字段含义,替换数据来源

先看案例 CSV 的列名和单位,再把代码中的模拟部分替换成 read_csv、read_excel 或数据库查询。不要只让新表的列名碰巧相同:订单数是否去重、收入是否退款后、时间属于哪个时区,都决定图究竟表达什么。

例如堆叠收入图要求同一季度的三类收入互斥;桑基图要求节点编号与边表对应;组织树要求每个下级只有一个正式上级。数据类型对了,业务含义仍可能不对。

第二步:把绘图前检查留住

比例求和、余额恒等式、价格上下界和流量守恒都可以写成断言。它们不是为了把错误藏起来,而是让脚本在数据不满足前提时停下来。

缺失值与异常值应分别处理:缺失表示没有观测,异常表示有观测但值得复核。不要因为都影响图形外观,就统一填零或直接删除。第 28 例与第 49 例可以作为这两种问题的起点。

第三步:检查表达是否超过证据

看到了正相关,就写当前样本存在正相关;看到了长篇负向反馈,就写进入人工复核队列。把"证明有效""找到了原因""识别准确"留给有相应设计与验证的数据。

不确定性也要说清对象。标准差描述个体离散程度,标准误与置信区间描述估计的不确定性,不能因为它们都能画成误差棒就互换含义。Seaborn 误差棒说明对这两类用途有清楚区分。

第四步:最后再调整排版

先确认图名与图形结构一致,再调整标题、字号和颜色。类别颜色保持稳定,连续值使用有顺序的色阶,有正负方向时才使用以零或目标值为中心的发散色阶。

本指南使用静态图作为正文插图。手机上遇到散点矩阵、网络和三维曲面,请点开原图或查看配套交互版;如果只挑其中一图放进日常报告,最好围绕那一个问题重新放大标签与注释。

以上就是这次的 50 个案例。建议先选一张与你手头问题最接近的图,跑通以后再换数据;确认计算与含义没有变化,再考虑配色和排版。能把一个问题说明白,比再收藏十张图更有用。如果你在实践过程中有新的思考、改进了某个案例的写法,也欢迎分享到云栈社区的技术论坛,和大家一起讨论。

附录:公共模块完整实现

下面文件保存为 cases/common_v1_0_2.py,与 50 个案例脚本放在同一目录。 start 创建独立随机数生成器与画布,finish 保存 CSV、图像与指标。它不替代案例中的数据生成和绘图逻辑。


"""v1.0.2 | 才哥AGI | 2026-09-13 | 统一导出与中文字体。"""

from pathlib import Path
import os, json
import numpy as np
import pandas as pd
import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib import font_manager
import seaborn as sns

ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "outputs"
OUT.mkdir(exist_ok=True)
FONT = None
for name in [
    "Microsoft YaHei",
    "Noto Sans CJK SC",
    "SimHei",
    "PingFang SC",
]:
    try:
        FONT = font_manager.findfont(
            name, fallback_to_default=False
        )
        break
    except ValueError:
        pass
if FONT is None:
    raise RuntimeError(
        "请安装 Noto Sans CJK SC 中文字体后重新运行。"
    )
FONT_NAME = font_manager.FontProperties(fname=FONT).get_name()
COLORS = [
    "#188568",
    "#4878B7",
    "#E59B42",
    "#9566AA",
    "#CB6677",
    "#5D9FAD",
]
sns.set_theme(style="whitegrid", font=FONT_NAME, palette=COLORS)
plt.rcParams.update(
    {
        "axes.unicode_minus": False,
        "font.size": 12,
        "axes.titlesize": 16,
        "axes.labelsize": 12,
        "figure.dpi": 150,
        "savefig.dpi": 170,
        "svg.fonttype": "path",
        "axes.spines.top": False,
        "axes.spines.right": False,
    }
)
(
    os.environ.setdefault(
        "BROWSER_PATH",
        r"C:\Program Files\Google\Chrome\Application\chrome.exe",
    )
    if os.name == "nt"
    else None
)

def start(n, title, size=(8, 5), **kwargs):
    rng = np.random.default_rng(20260913 + n)
    fig, ax = plt.subplots(figsize=size, **kwargs)
    return rng, fig, ax

def finish(n, title, fig, df, metrics=None):
    stem = f"{n:02d}_v1.0.0"
    if not isinstance(df, dict):
        df = {"data": df}
    tables = []
    for key, table in df.items():
        filename = f"{stem}_{key}.csv"
        table.to_csv(
            OUT / filename,
            index=False,
            encoding="utf-8-sig",
            float_format="%.9g",
        )
        tables.append(filename)
    if hasattr(fig, "write_image"):
        fig.update_layout(
            template="plotly_white",
            width=1000,
            height=650,
            title=dict(text=f"{n:02d} · {title}", x=0.04),
            font=dict(family=FONT_NAME, size=18),
            colorway=COLORS,
            margin=dict(t=110, b=90, l=75, r=65),
        )
        fig.add_annotation(
            text="模拟数据 · 可以叫我才哥 · v1.0.2",
            x=0,
            y=-0.13,
            xref="paper",
            yref="paper",
            showarrow=False,
            font=dict(size=14, color="#64746C"),
        )
        fig.write_html(
            OUT / f"{n:02d}_v1.0.2.html",
            include_plotlyjs="directory",
            auto_open=False,
        )
        fig.write_image(OUT / f"{n:02d}_v1.0.2.png", scale=1.6)
        fig.write_image(OUT / f"{n:02d}_v1.0.2.svg")
    else:
        fig.suptitle(
            f"{n:02d} · {title}",
            x=0.08,
            ha="left",
            fontweight="bold",
            color="#1C493A",
        )
        fig.text(
            0.08,
            0.012,
            "模拟数据 · 可以叫我才哥 · v1.0.2",
            fontsize=9,
            color="#64746C",
        )
        fig.tight_layout(rect=(0, 0.04, 1, 0.94))
        fig.savefig(OUT / f"{n:02d}_v1.0.2.png")
        fig.savefig(OUT / f"{n:02d}_v1.0.2.svg")
        plt.close(fig)
    record = dict(
        case=n,
        title=title,
        seed=20260913 + n,
        synthetic=True,
        tables=tables,
        metrics=metrics or {},
    )
    (OUT / f"{stem}_metrics.json").write_text(
        json.dumps(
            record,
            ensure_ascii=False,
            indent=2,
            allow_nan=False,
            default=lambda v: v.item(),
        ),
        encoding="utf-8",
    )
    print(f"PASS {n:02d}{title}", flush=True)



上一篇:一天近 300 万次调用,Anthropic 说有人在蒸馏 Claude
下一篇:嵌入式AI编程工具环境怎么搭:Cursor、Claude Code、Codex 接入 STM32CubeIDE 与 ESP-IDF
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-9-21 01:17 , Processed in 1.022104 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表