跳转至

单维度分布:什么情况画什么图

单维度分布只回答一个问题:这批数据按某几个标签分组以后,每一组有多少。值用的是 count(人次)或 nunique(人数)这类外延量,能加总、能算份额,所以可以画成扇区、面积或者堆叠。均分这类内涵量放到联合分析里讲。

用什么图,只看两件事:

graph TD
    A[要看分布的特征] --> B{"特征之间是包含关系吗?"}
    B -->|"是:年级→班级"| C[矩形树图 + 旭日图]
    B -->|否| D{几个特征}
    D -->|1 个| E[环形图]
    D -->|2 个| F[热力图 + 堆叠条形图]
    D -->|3 个及以上| G[每个特征一张环形图<br/>再加一张笛卡尔积环形图<br/>第 n 名起合并成「其他」]

下面每一节的顺序都一样:pandas 代码 → pivot 结果 → 图 → 怎么读。所有代码共用这段开头,数据是 scores.csv:

import pandas as pd

df = pd.read_csv("scores.csv")
# 定序特征显式给顺序,不然按字符串排:「高三」排到「高二」前面,「不及格」排到「优」前面
df["分数段"] = pd.Categorical(df["分数段"], ["优", "良", "及格", "不及格"])
df["科目"] = pd.Categorical(df["科目"], ["语文", "数学", "英语"])
df["年级"] = pd.Categorical(df["年级"], ["高一", "高二", "高三"])
df["班级"] = pd.Categorical(df["班级"], df["班级"].unique())  # CSV 里已按年级排好

一个特征:环形图

pt = df.pivot_table(index="分数段", aggfunc="size", observed=False)
分数段 人次
优 122
良 214
及格 429
不及格 66
{
  "height": 340,
  "tooltip": {"trigger": "item", "formatter": "{b}:{c} 人次({d}%)"},
  "legend": {"bottom": 0},
  "series": [{"type": "pie", "radius": ["42%", "65%"], "center": ["50%", "45%"], "label": {"formatter": "{b}\n{d}%"}, "data": [{"name": "优", "value": 122, "itemStyle": {"color": "#2e7d32"}}, {"name": "良", "value": 214, "itemStyle": {"color": "#1976d2"}}, {"name": "及格", "value": 429, "itemStyle": {"color": "#f9a825"}}, {"name": "不及格", "value": 66, "itemStyle": {"color": "#c62828"}}]}]
}
  • 只有一个特征时,关心的是各部分占整体几成,这正是扇区擅长表达的;中间挖空,比实心饼图更容易比较相邻扇区的弧长
  • 这里数的是人次:一个学生三科各算一次。全校 277 人,831 人次,一半多落在「及格」,不及格占 7.9%
  • 分数段是定序特征,顺序要显式告诉 pandas(开头那段 pd.Categorical),否则表和图都会按字符串排成「不及格、优、及格、良」

两个平级特征:热力图 + 堆叠条形图

两个特征能交叉,前提是它们是同一个个体的两个属性:一个学生既有语文成绩,也有英语成绩,交叉才能回答「语文优的人,英语怎么样」。长表里语文和英语藏在「科目」这一列的值里,所以先转宽,一人一行,三科的分数各占一列;然后每一科按自己的标准分箱:语文沿用优、良、及格、不及格四档,英语只分好、差两档(80 分为界)。两条轴的刻度不一样,正好提醒你这是两个不同的特征在交叉,而不是同一个「分数段」在三科上重复。语文段和英语段没有包含关系,四个语文档各自都可能配上两个英语档,组合就是 4 × 2 的笛卡尔积:

wide = df.pivot(index=["班级", "姓名"], columns="科目", values="分数").reset_index()  # 转宽:一人一行,三科三列
bins = {  # 每一科自己的分箱标准,标签从低到高;数学的三档下一节用
    "语文": ([0, 60, 80, 90, 101], ["不及格", "及格", "良", "优"]),
    "数学": ([0, 60, 90, 101], ["没学会", "普通人", "天才"]),
    "英语": ([0, 80, 101], ["差", "好"]),
}
for subj, (edges, labels) in bins.items():
    wide[subj + "段"] = pd.cut(wide[subj], edges, right=False, labels=labels)

pt = wide.pivot_table(index="语文段", columns="英语段", aggfunc="size", observed=False)
语文段\英语段 差 好
不及格 18 0
及格 115 13
良 28 56
优 2 45

转宽以后一人一行,size 数出来的就是人数(277),不再是人次。pd.cut 的标签按从低到高给,所以这一节和下一节的表、轴都是从低到高。热力图把这张表原样涂色,看的是哪一格最深、哪片区域是空的:

{
  "height": 340,
  "tooltip": {"position": "top"},
  "grid": {"left": 70, "right": 30, "top": 30, "bottom": 90},
  "xAxis": {"type": "category", "name": "英语段", "nameLocation": "middle", "nameGap": 28, "data": ["差", "好"], "splitArea": {"show": true}},
  "yAxis": {"type": "category", "name": "语文段", "data": ["不及格", "及格", "良", "优"], "splitArea": {"show": true}},
  "visualMap": {"min": 0, "max": 115, "calculable": true, "orient": "horizontal", "left": "center", "bottom": 0, "inRange": {"color": ["#f7fbff", "#08306b"]}},
  "series": [{"type": "heatmap", "label": {"show": true}, "data": [[0, 0, 18], [1, 0, 0], [0, 1, 115], [1, 1, 13], [0, 2, 28], [1, 2, 56], [0, 3, 2], [1, 3, 45]]}]
}

堆叠条形图看的是每一行怎么拆开。拿谁当纵轴,要看你想比较什么;在代码里只是 pt 和 pt.T 的区别:

{
  "height": 300,
  "tooltip": {"trigger": "axis", "axisPointer": {"type": "shadow"}},
  "legend": {"top": 0},
  "grid": {"left": 70, "right": 30, "top": 40, "bottom": 30},
  "xAxis": {"type": "value"},
  "yAxis": {"type": "category", "name": "语文段", "data": ["不及格", "及格", "良", "优"]},
  "series": [{"name": "英语 差", "type": "bar", "stack": "总量", "emphasis": {"focus": "series"}, "data": [18, 115, 28, 2], "itemStyle": {"color": "#c62828"}}, {"name": "英语 好", "type": "bar", "stack": "总量", "emphasis": {"focus": "series"}, "data": [0, 13, 56, 45], "itemStyle": {"color": "#2e7d32"}}]
}
{
  "height": 260,
  "tooltip": {"trigger": "axis", "axisPointer": {"type": "shadow"}},
  "legend": {"top": 0},
  "grid": {"left": 70, "right": 30, "top": 40, "bottom": 30},
  "xAxis": {"type": "value"},
  "yAxis": {"type": "category", "name": "英语段", "data": ["差", "好"]},
  "series": [{"name": "语文 不及格", "type": "bar", "stack": "总量", "emphasis": {"focus": "series"}, "data": [18, 0], "itemStyle": {"color": "#c62828"}}, {"name": "语文 及格", "type": "bar", "stack": "总量", "emphasis": {"focus": "series"}, "data": [115, 13], "itemStyle": {"color": "#f9a825"}}, {"name": "语文 良", "type": "bar", "stack": "总量", "emphasis": {"focus": "series"}, "data": [28, 56], "itemStyle": {"color": "#1976d2"}}, {"name": "语文 优", "type": "bar", "stack": "总量", "emphasis": {"focus": "series"}, "data": [2, 45], "itemStyle": {"color": "#2e7d32"}}]
}
  • 两张图用的是同一张表,热力图适合找极值格和空白区,堆叠条形图适合比较每一行的构成
  • 深色从左下走到右上:语文不及格的 18 人英语全差,语文及格的 128 人里 115 人英语差,语文优的 47 人里 45 人英语好。「良」是分水岭,56 比 28,从这一档起英语好的开始占多数。语文和英语高度同向(分数本身的相关系数 0.89)
  • 右下角是 0,左上角只有 2:语文不及格而英语好的人不存在,语文优而英语差的只有 2 个。两条轴的刻度不一样,对角线照样看得出来 —— 笛卡尔积不要求两个特征用同一套类别
  • 点堆叠条形图的图例,可以把某一档隐藏,剩下的部分会重新堆叠

科目 × 分数段 不算两个特征

长表不转宽,直接 df.pivot_table(index="科目", columns="分数段", aggfunc="size") 也能出一张 3 × 4 的表:

科目 优 良 及格 不及格
语文 47 84 128 18
数学 28 63 163 23
英语 47 67 138 25

但「科目」是 melt 出来的列,每个学生在三行里各出现一次,行与行之间没有共同的个体。这张表只是三条一维分布并排(每行合计都是 277),能说「数学及格最多、优最少,是三科里最难的一科」,却没有对角线可看 —— 涂成热力图,看到的只是三科各自的难度。它该画成三张环形图或一张分组条形图。它之所以看起来像交叉表,是因为 CSV 里的「分数段」三科用的是同一套标准;一旦每科按自己的标准分箱,这张表连列名都对不齐,就再也摆不出来了。同理,「科目」单独一列数出来永远是三等分,只能当数据体检用:哪天不是三等分了,说明有人缺考,或者数据漏了。

三个及以上:先逐个拆开看,再看组合

再加上数学,就变成语文 × 数学 × 英语三个平级特征,三条轴各有各的刻度:语文四档、数学三档(天才、普通人、没学会)、英语两档。直接画 4 × 3 × 2 = 24 格的交叉,人读不过来。所以先每个特征单独画一张环形图:

for col in ["语文段", "数学段", "英语段"]:
    print(wide.pivot_table(index=col, aggfunc="size", observed=False))
语文段 人数
不及格 18
及格 128
良 84
优 47
{
  "height": 300,
  "tooltip": {"trigger": "item", "formatter": "{b}:{c} 人({d}%)"},
  "legend": {"bottom": 0},
  "series": [{"type": "pie", "radius": ["42%", "65%"], "center": ["50%", "45%"], "label": {"formatter": "{b}\n{d}%"}, "data": [{"name": "不及格", "value": 18, "itemStyle": {"color": "#c62828"}}, {"name": "及格", "value": 128, "itemStyle": {"color": "#f9a825"}}, {"name": "良", "value": 84, "itemStyle": {"color": "#1976d2"}}, {"name": "优", "value": 47, "itemStyle": {"color": "#2e7d32"}}]}]
}
数学段 人数
没学会 23
普通人 226
天才 28
{
  "height": 300,
  "tooltip": {"trigger": "item", "formatter": "{b}:{c} 人({d}%)"},
  "legend": {"bottom": 0},
  "series": [{"type": "pie", "radius": ["42%", "65%"], "center": ["50%", "45%"], "label": {"formatter": "{b}\n{d}%"}, "data": [{"name": "没学会", "value": 23, "itemStyle": {"color": "#c62828"}}, {"name": "普通人", "value": 226, "itemStyle": {"color": "#f9a825"}}, {"name": "天才", "value": 28, "itemStyle": {"color": "#2e7d32"}}]}]
}
英语段 人数
差 163
好 114
{
  "height": 300,
  "tooltip": {"trigger": "item", "formatter": "{b}:{c} 人({d}%)"},
  "legend": {"bottom": 0},
  "series": [{"type": "pie", "radius": ["42%", "65%"], "center": ["50%", "45%"], "label": {"formatter": "{b}\n{d}%"}, "data": [{"name": "差", "value": 163, "itemStyle": {"color": "#c62828"}}, {"name": "好", "value": 114, "itemStyle": {"color": "#2e7d32"}}]}]
}

三张图的档位各不相同,这是各科自己分箱的结果。数学的「天才」28 人和「没学会」23 人差不多多,两头都是少数,中间的「普通人」占了八成。

然后看组合。把三列标签拼成一列组合标签,数的是每种组合的人数:

combo = wide.pivot_table(index=["语文段", "数学段", "英语段"], aggfunc="size", observed=False)
combo.index = combo.index.map("-".join)  # 三列标签拼成一列组合标签
combo = combo.sort_values(ascending=False)
展开:全部 24 种组合
语文-数学-英语 人数
及格-普通人-差 100
良-普通人-好 48
优-普通人-好 27
良-普通人-差 27
优-天才-好 18
及格-没学会-差 14
及格-普通人-好 13
不及格-没学会-差 9
不及格-普通人-差 9
良-天才-好 8
优-普通人-差 2
良-天才-差 1
及格-天才-差 1
不及格-没学会-好 0
不及格-普通人-好 0
不及格-天才-差 0
不及格-天才-好 0
及格-没学会-好 0
及格-天才-好 0
良-没学会-差 0
良-没学会-好 0
优-没学会-差 0
优-没学会-好 0
优-天才-差 0

24 个扇区的环形图没法读,所以按频次排序以后,第 n 名起全部合并成「其他」(这里 n = 10):

def merge_tail(s, n=10):
    """已按频次降序排好;第 n 名起全部并成「其他」"""
    return pd.concat([s.iloc[:n - 1], pd.Series({"其他": s.iloc[n - 1:].sum()})])

merged = merge_tail(combo)
语文-数学-英语 人数
及格-普通人-差 100
良-普通人-好 48
优-普通人-好 27
良-普通人-差 27
优-天才-好 18
及格-没学会-差 14
及格-普通人-好 13
不及格-没学会-差 9
不及格-普通人-差 9
其他 12
{
  "height": 380,
  "tooltip": {"trigger": "item", "formatter": "{b}:{c} 人({d}%)"},
  "series": [{"type": "pie", "radius": ["40%", "68%"], "label": {"formatter": "{b} {d}%"}, "data": [{"name": "及格-普通人-差", "value": 100}, {"name": "良-普通人-好", "value": 48}, {"name": "优-普通人-好", "value": 27}, {"name": "良-普通人-差", "value": 27}, {"name": "优-天才-好", "value": 18}, {"name": "及格-没学会-差", "value": 14}, {"name": "及格-普通人-好", "value": 13}, {"name": "不及格-没学会-差", "value": 9}, {"name": "不及格-普通人-差", "value": 9}, {"name": "其他", "value": 12, "itemStyle": {"color": "#9e9e9e"}}]}]
}
  • 组合不是包含关系,所以这里只能画环形图,不能画旭日图 —— 旭日图的内圈在暗示「语文段包含数学段」,可数学段并不属于某个语文档
  • 「及格-普通人-差」一种组合就有 100 人,占 36%:三科都在中间档的学生是主流。往上是「良-普通人-好」「优-普通人-好」,往下是「及格-没学会-差」「不及格-没学会-差」,三科一起升、一起降
  • 24 种组合里 11 种是 0,全是「一科顶、一科底」的反向组合:语文优而数学没学会、语文不及格而数学天才,一个人都没有。observed=False 把它们留在表里,0 本身就是信息:这些组合不是没数到,是根本不存在
  • 三条轴一条四档、一条三档、一条两档,组合标签照样拼得出来。笛卡尔积只要求每个特征有自己的一组类别,不要求各特征的类别一样多、叫法一样
  • 「其他」只有 12 人,占 4%。这里尾巴不长,n = 10 绰绰有余;换成几十个城市 × 上百种商品,尾巴会比任何一个头部组合都大,那才是必须合并的场景

有包含关系:矩形树图 + 旭日图

年级和班级是包含关系:一个班只属于一个年级。这时候要数人数,所以按姓名去重(nunique),不是数行:

pt = df.pivot_table(index=["年级", "班级"], values="姓名", aggfunc="nunique", observed=True)
年级 班级 人数
高一 高一1班 48
高一 高一2班 28
高一 高一3班 36
高二 高二1班 22
高二 高二2班 42
高三 高三1班 38
高三 高三2班 30
高三 高三3班 33

注意这里是 observed=True。如果写成 observed=False,pandas 会把 3 个年级 × 8 个班做成笛卡尔积,得到 24 行,其中 16 行是「高一 × 高三2班」这种根本不存在的组合。平级特征要保留空组合,包含关系要去掉空组合 —— 目录页里说「组合标签的两种关系会影响可视化」,落到代码上就是这一个参数。

包含关系天然是一棵树,所以用画树的图:

{
  "height": 380,
  "tooltip": {"formatter": "{b}:{c} 人"},
  "series": [{"type": "treemap", "name": "全校", "data": [{"name": "高一", "children": [{"name": "高一1班", "value": 48}, {"name": "高一2班", "value": 28}, {"name": "高一3班", "value": 36}]}, {"name": "高二", "children": [{"name": "高二1班", "value": 22}, {"name": "高二2班", "value": 42}]}, {"name": "高三", "children": [{"name": "高三1班", "value": 38}, {"name": "高三2班", "value": 30}, {"name": "高三3班", "value": 33}]}], "roam": false, "sort": null, "breadcrumb": {"show": true}, "upperLabel": {"show": true, "height": 24, "formatter": "{b}  {c} 人"}, "label": {"formatter": "{b}\n{c} 人"}, "levels": [{"itemStyle": {"borderColor": "#fff", "borderWidth": 0, "gapWidth": 4}, "upperLabel": {"show": false}}, {"itemStyle": {"borderColor": "#fff", "borderWidth": 4, "gapWidth": 2}}, {"itemStyle": {"borderColor": "#fff", "borderWidth": 2, "gapWidth": 2}}]}]
}
{
  "height": 400,
  "tooltip": {"formatter": "{b}:{c} 人"},
  "series": [{"type": "sunburst", "data": [{"name": "高一", "children": [{"name": "高一1班", "value": 48}, {"name": "高一2班", "value": 28}, {"name": "高一3班", "value": 36}]}, {"name": "高二", "children": [{"name": "高二1班", "value": 22}, {"name": "高二2班", "value": 42}]}, {"name": "高三", "children": [{"name": "高三1班", "value": 38}, {"name": "高三2班", "value": 30}, {"name": "高三3班", "value": 33}]}], "radius": ["15%", "90%"], "sort": null, "label": {"rotate": "radial"}, "levels": [{}, {"r0": "15%", "r": "50%", "label": {"rotate": 0}}, {"r0": "50%", "r": "90%"}]}]
}
  • 两张图表达的是同一件事:面积(或弧长)= 人数,每个班严格落在它的年级里面。矩形树图更方便比较面积,旭日图更方便看层级
  • 矩形树图点一个年级会钻进去,看这个年级内部各班的占比;点上方的面包屑可以退回全校
  • 面积只能是外延量。把各班均分拿来当面积,高一的面积就成了「三个班的均分相加」,这个数没有意义

继续往下看各班的分数段分布、均分和离散程度,就是联合分析的事了。