跳转至

联合分析:数值和占比、外延和内涵

单维度分布只数每一组有多少。联合分析要拿一个特征当主体,看另一个特征在它下面怎么分布:以班级为主体看分数段,比较的是班和班之间的差别;以分数段为主体看班级,比较的是每个分数段由哪些班组成。

聚合函数也不再只是 count。每一节都在回答同一个问题:这个值能不能加总。能加总的外延量(人次、总分)可以堆叠;不能加总的内涵量(均分、中位数、标准差)只能并排放、涂成热力图,或者画成箱线图。

开头的代码跟上一篇一样:

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="班级", columns="分数段", aggfunc="size", observed=False)
share = pt.div(pt.sum(axis=1), axis=0)  # 每一行除以行合计 = 班内占比
班级 优 良 及格 不及格
高一1班 11 21 84 28
高一2班 21 31 32 0
高一3班 15 32 58 3
高二1班 3 5 40 18
高二2班 18 24 82 2
高三1班 9 34 70 1
高三2班 31 24 22 13
高三3班 14 43 41 1
班级 优 良 及格 不及格
高一1班 7.6% 14.6% 58.3% 19.4%
高一2班 25.0% 36.9% 38.1% 0.0%
高一3班 13.9% 29.6% 53.7% 2.8%
高二1班 4.5% 7.6% 60.6% 27.3%
高二2班 14.3% 19.0% 65.1% 1.6%
高三1班 7.9% 29.8% 61.4% 0.9%
高三2班 34.4% 26.7% 24.4% 14.4%
高三3班 14.1% 43.4% 41.4% 1.0%

先用堆叠柱状图看整体,每根柱子是一个班,高度就是这个班的总人次:

{
  "height": 340,
  "tooltip": {"trigger": "axis", "axisPointer": {"type": "shadow"}},
  "legend": {"top": 0},
  "grid": {"left": 70, "right": 30, "top": 40, "bottom": 30},
  "xAxis": {"type": "category", "data": ["高一1班", "高一2班", "高一3班", "高二1班", "高二2班", "高三1班", "高三2班", "高三3班"], "inverse": false},
  "yAxis": {"type": "value"},
  "series": [{"name": "优", "type": "bar", "stack": "总量", "emphasis": {"focus": "series"}, "data": [11, 21, 15, 3, 18, 9, 31, 14], "itemStyle": {"color": "#2e7d32"}}, {"name": "良", "type": "bar", "stack": "总量", "emphasis": {"focus": "series"}, "data": [21, 31, 32, 5, 24, 34, 24, 43], "itemStyle": {"color": "#1976d2"}}, {"name": "及格", "type": "bar", "stack": "总量", "emphasis": {"focus": "series"}, "data": [84, 32, 58, 40, 82, 70, 22, 41], "itemStyle": {"color": "#f9a825"}}, {"name": "不及格", "type": "bar", "stack": "总量", "emphasis": {"focus": "series"}, "data": [28, 0, 3, 18, 2, 1, 13, 1], "itemStyle": {"color": "#c62828"}}]
}

然后只看「不及格」这一类:柱子是不及格人次(左轴),折线是它在班内的占比(右轴):

{
  "height": 340,
  "tooltip": {"trigger": "axis"},
  "legend": {"top": 0},
  "grid": {"left": 60, "right": 60, "top": 40, "bottom": 30},
  "xAxis": {"type": "category", "data": ["高一1班", "高一2班", "高一3班", "高二1班", "高二2班", "高三1班", "高三2班", "高三3班"]},
  "yAxis": [{"type": "value", "name": "不及格人次"}, {"type": "value", "name": "班内占比", "axisLabel": {"formatter": "{value}%"}, "splitLine": {"show": false}}],
  "series": [{"name": "不及格人次", "type": "bar", "itemStyle": {"color": "#c62828"}, "data": [28, 0, 3, 18, 2, 1, 13, 1]}, {"name": "班内占比", "type": "line", "yAxisIndex": 1, "symbolSize": 8, "label": {"show": true, "position": "top", "formatter": "{c}%", "fontWeight": "bold"}, "data": [19.4, 0.0, 2.8, 27.3, 1.6, 0.9, 14.4, 1.0]}]
}
  • 按人次看,高一1班的不及格最多(28),像是全校最差的班。可它是全校最大的班(144 人次),班内占比只有 19.4%
  • 高二1班只有 18 人次不及格,但全班才 66 人次,占比 27.3%,比高一1班高出一大截
  • 人次回答「问题出在哪里最多」,占比回答「哪个班最严重」。要派老师去补课,看人次;要评估班级,看占比。两个数都要算,只看一个就会得出错的结论
  • pt.div(pt.sum(axis=1), axis=0) 是按行归一化,等价于 pd.crosstab(df["班级"], df["分数段"], normalize="index")

行列对调:以分数段为主体

把 index 和 columns 对调,主体就换成了分数段:

pt = df.pivot_table(index="分数段", columns="班级", aggfunc="size", observed=False)
share = pt.div(pt.sum(axis=1), axis=0)  # 每个分数段内部,各班占几成
分数段 高一1班 高一2班 高一3班 高二1班 高二2班 高三1班 高三2班 高三3班
优 11 21 15 3 18 9 31 14
良 21 31 32 5 24 34 24 43
及格 84 32 58 40 82 70 22 41
不及格 28 0 3 18 2 1 13 1
分数段 高一1班 高一2班 高一3班 高二1班 高二2班 高三1班 高三2班 高三3班
优 9.0% 17.2% 12.3% 2.5% 14.8% 7.4% 25.4% 11.5%
良 9.8% 14.5% 15.0% 2.3% 11.2% 15.9% 11.2% 20.1%
及格 19.6% 7.5% 13.5% 9.3% 19.1% 16.3% 5.1% 9.6%
不及格 42.4% 0.0% 4.5% 27.3% 3.0% 1.5% 19.7% 1.5%
{
  "height": 360,
  "tooltip": {"trigger": "axis", "axisPointer": {"type": "shadow"}},
  "legend": {"top": 0},
  "grid": {"left": 70, "right": 30, "top": 40, "bottom": 30},
  "xAxis": {"type": "category", "data": ["优", "良", "及格", "不及格"], "inverse": false},
  "yAxis": {"type": "value"},
  "series": [{"name": "高一1班", "type": "bar", "stack": "总量", "emphasis": {"focus": "series"}, "data": [11, 21, 84, 28]}, {"name": "高一2班", "type": "bar", "stack": "总量", "emphasis": {"focus": "series"}, "data": [21, 31, 32, 0]}, {"name": "高一3班", "type": "bar", "stack": "总量", "emphasis": {"focus": "series"}, "data": [15, 32, 58, 3]}, {"name": "高二1班", "type": "bar", "stack": "总量", "emphasis": {"focus": "series"}, "data": [3, 5, 40, 18]}, {"name": "高二2班", "type": "bar", "stack": "总量", "emphasis": {"focus": "series"}, "data": [18, 24, 82, 2]}, {"name": "高三1班", "type": "bar", "stack": "总量", "emphasis": {"focus": "series"}, "data": [9, 34, 70, 1]}, {"name": "高三2班", "type": "bar", "stack": "总量", "emphasis": {"focus": "series"}, "data": [31, 24, 22, 13]}, {"name": "高三3班", "type": "bar", "stack": "总量", "emphasis": {"focus": "series"}, "data": [14, 43, 41, 1]}]
}

聚焦高三2班:柱子是它在每个分数段里的人次,折线是它在这个分数段里占几成:

{
  "height": 340,
  "tooltip": {"trigger": "axis"},
  "legend": {"top": 0},
  "grid": {"left": 60, "right": 60, "top": 40, "bottom": 30},
  "xAxis": {"type": "category", "data": ["优", "良", "及格", "不及格"]},
  "yAxis": [{"type": "value", "name": "高三2班人次"}, {"type": "value", "name": "段内占比", "axisLabel": {"formatter": "{value}%"}, "splitLine": {"show": false}}],
  "series": [{"name": "高三2班人次", "type": "bar", "itemStyle": {"color": "#7b1fa2"}, "data": [31, 24, 22, 13]}, {"name": "段内占比", "type": "line", "yAxisIndex": 1, "symbolSize": 8, "label": {"show": true, "position": "top", "formatter": "{c}%", "fontWeight": "bold"}, "data": [25.4, 11.2, 5.1, 19.7]}]
}
  • 高三2班的人次占全校 10.8%,这是一个基准线。如果这个班很「普通」,它在每个分数段里的占比都应该在这条线附近
  • 实际上,全校的「优」里它占 25.4%,「不及格」里它占 19.7%,而「及格」里只占 5.1%:两头多,中间少。这个班两极分化,第四节的箱线图会从另一个角度再看到它
  • 同一张 pivot 表转置一下,问题就从「这个班的学生都在哪个分数段」变成了「这个分数段的学生都来自哪个班」

横轴和类别都先合并「其他」

这里只有 8 个班、4 个分数段,所以不用合并。换成几十个城市、上百种商品时,横轴主体和堆叠类别都要先按频次排序,用上一篇的 merge_tail把第 n 名以后的并成「其他」,然后再 pivot,否则图会糊成一片。

总分能加,但加出来的数不能比

总分是外延量,技术上可以加总,也可以堆叠。那它能不能用来比较各班?

pt = df.pivot_table(index="班级", values="分数", aggfunc=["sum", "count", "mean"], observed=False)
班级 总分 人次 均分
高一1班 10039 144 69.7
高一2班 6965 84 82.9
高一3班 8420 108 78.0
高二1班 4380 66 66.4
高二2班 9652 126 76.6
高三1班 8860 114 77.7
高三2班 7200 90 80.0
高三3班 7892 99 79.7
{
  "height": 340,
  "tooltip": {"trigger": "axis"},
  "legend": {"top": 0},
  "grid": {"left": 70, "right": 60, "top": 40, "bottom": 30},
  "xAxis": {"type": "category", "data": ["高一1班", "高一2班", "高一3班", "高二1班", "高二2班", "高三1班", "高三2班", "高三3班"]},
  "yAxis": [{"type": "value", "name": "总分"}, {"type": "value", "name": "均分", "min": 60, "max": 90, "splitLine": {"show": false}}],
  "series": [{"name": "总分", "type": "bar", "itemStyle": {"color": "#90a4ae"}, "data": [10039, 6965, 8420, 4380, 9652, 8860, 7200, 7892]}, {"name": "均分", "type": "line", "yAxisIndex": 1, "symbolSize": 8, "label": {"show": true}, "data": [69.7, 82.9, 78.0, 66.4, 76.6, 77.7, 80.0, 79.7]}]
}
  • 按总分排,高一1班第一(10039);按均分排,它倒数第二(69.7)。总分高只是因为人多
  • 高一2班的总分几乎垫底,均分却是全校最高(82.9)
  • 目录页里说「外延量加总也不见得有意义」,这里就是一个例子:总分 = 均分 × 人次,把班级规模混了进来。它唯一的用处是当均分的分子

均分只能并排放,不能叠

均分是内涵量,两个班的均分相加没有意义,所以不能画成堆叠柱,只能用热力图或者分组柱状图:

pt = df.pivot_table(index="班级", columns="科目", values="分数", aggfunc="mean", observed=False)
班级 语文 数学 英语
高一1班 71.4 68.3 69.5
高一2班 84.8 81.0 82.9
高一3班 78.2 76.1 79.6
高二1班 68.6 64.8 65.6
高二2班 78.1 74.6 77.1
高三1班 79.8 75.6 77.8
高三2班 81.0 77.5 81.6
高三3班 81.1 78.0 80.1
{
  "height": 400,
  "tooltip": {"position": "top"},
  "grid": {"left": 70, "right": 30, "top": 20, "bottom": 90},
  "xAxis": {"type": "category", "data": ["语文", "数学", "英语"], "splitArea": {"show": true}},
  "yAxis": {"type": "category", "data": ["高一1班", "高一2班", "高一3班", "高二1班", "高二2班", "高三1班", "高三2班", "高三3班"], "inverse": true, "splitArea": {"show": true}},
  "visualMap": {"min": 60, "max": 90, "calculable": true, "orient": "horizontal", "left": "center", "bottom": 0, "inRange": {"color": ["#c62828", "#fff9c4", "#2e7d32"]}},
  "series": [{"type": "heatmap", "label": {"show": true}, "data": [[0, 0, 71.4], [1, 0, 68.3], [2, 0, 69.5], [0, 1, 84.8], [1, 1, 81.0], [2, 1, 82.9], [0, 2, 78.2], [1, 2, 76.1], [2, 2, 79.6], [0, 3, 68.6], [1, 3, 64.8], [2, 3, 65.6], [0, 4, 78.1], [1, 4, 74.6], [2, 4, 77.1], [0, 5, 79.8], [1, 5, 75.6], [2, 5, 77.8], [0, 6, 81.0], [1, 6, 77.5], [2, 6, 81.6], [0, 7, 81.1], [1, 7, 78.0], [2, 7, 80.1]]}]
}
{
  "height": 340,
  "tooltip": {"trigger": "axis"},
  "legend": {"top": 0},
  "grid": {"left": 50, "right": 30, "top": 40, "bottom": 30},
  "xAxis": {"type": "category", "data": ["高一1班", "高一2班", "高一3班", "高二1班", "高二2班", "高三1班", "高三2班", "高三3班"]},
  "yAxis": {"type": "value", "min": 50, "max": 90},
  "series": [{"name": "语文", "type": "bar", "data": [71.4, 84.8, 78.2, 68.6, 78.1, 79.8, 81.0, 81.1]}, {"name": "数学", "type": "bar", "data": [68.3, 81.0, 76.1, 64.8, 74.6, 75.6, 77.5, 78.0]}, {"name": "英语", "type": "bar", "data": [69.5, 82.9, 79.6, 65.6, 77.1, 77.8, 81.6, 80.1]}]
}
  • 热力图适合一眼找出最强和最弱的格子(高一2班语文 84.8、高二1班数学 64.8);分组柱状图适合比较同一个班内部的三科
  • 每个班都是数学最低,这跟单维度分布里「数学及格最多、优最少」对得上

年级均分:先加权,再平均

内涵量要往上汇总(班 → 年级),不能直接把各班均分再平均一次:

加权 = df.pivot_table(index="年级", values="分数", aggfunc="mean", observed=False)
各班均分 = df.pivot_table(index=["年级", "班级"], values="分数", aggfunc="mean", observed=True)
不加权 = 各班均分.groupby(level="年级", observed=True).mean()
年级 按人次加权 各班均分直接平均 差
高一 75.67 76.86 1.20
高二 73.08 71.48 -1.60
高三 79.05 79.15 0.10
  • 高二只有两个班:1 班 22 人、均分 66.4,2 班 42 人、均分 76.6。直接平均就是 (66.4 + 76.6) / 2 ≈ 71.5,等于让 22 个人和 42 个人拿一样的权重;从原始数据直接求均值(按人次加权)得到的才是 73.1
  • 高一正好反过来:人最多的 1 班均分最低,不加权的结果被高估了 1.2 分
  • 高三三个班人数差不多,两种算法只差 0.1。差多少取决于班级规模有多不均匀,不能指望「差不多就行」
  • 正确的做法永远是回到原始的长表,重新做一次 pivot_table —— 这也是保留长表的另一个理由

均分差不多,分布可以完全不同

均值只是一个数。要看中位数、离散程度和极值,就把 aggfunc 换成一个列表:

pt = df.pivot_table(index="班级", values="分数",
                    aggfunc=["mean", "median", "std", "min", "max"], observed=False)
box = df.groupby("班级", observed=False)["分数"].quantile([0, .25, .5, .75, 1]).unstack()
班级 mean median std min max
高一1班 69.7 69.0 13.1 39.0 100.0
高一2班 82.9 83.0 8.9 62.0 100.0
高一3班 78.0 79.0 10.0 57.0 98.0
高二1班 66.4 66.0 11.1 41.0 95.0
高二2班 76.6 75.0 10.2 57.0 100.0
高三1班 77.7 77.0 7.8 55.0 94.0
高三2班 80.0 85.5 15.2 43.0 100.0
高三3班 79.7 81.0 9.7 56.0 100.0

箱线图一次画出这五个数:箱子的上下边是四分位数,中间的线是中位数,须到最小值和最大值;红色菱形是均分。

{
  "height": 380,
  "tooltip": {"trigger": "item"},
  "grid": {"left": 50, "right": 30, "top": 20, "bottom": 30},
  "xAxis": {"type": "category", "data": ["高一1班", "高一2班", "高一3班", "高二1班", "高二2班", "高三1班", "高三2班", "高三3班"]},
  "yAxis": {"type": "value", "min": 30, "max": 100, "name": "分数"},
  "series": [{"type": "boxplot", "name": "分数", "data": [[39.0, 60.0, 69.0, 78.0, 100.0], [62.0, 76.0, 83.0, 89.25, 100.0], [57.0, 70.0, 79.0, 86.25, 98.0], [41.0, 59.0, 66.0, 73.0, 95.0], [57.0, 69.0, 75.0, 83.0, 100.0], [55.0, 74.0, 77.0, 82.75, 94.0], [43.0, 67.0, 85.5, 93.75, 100.0], [56.0, 72.5, 81.0, 85.5, 100.0]]}, {"type": "scatter", "name": "均分", "symbol": "diamond", "symbolSize": 10, "itemStyle": {"color": "#c62828"}, "data": [69.7, 82.9, 78.0, 66.4, 76.6, 77.7, 80.0, 79.7]}]
}
  • 高三1班和高三2班均分只差 2.3 分(77.7 和 80.0),只看均分会觉得两个班差不多
  • 可是高三1班的标准差只有 7.8,箱子很扁,学生水平很整齐;高三2班标准差 15.2,箱子被拉得很长,最低分 43、中位数 85.5 —— 一半人次在 85 分以上,另一拨在 60 分上下,全班 13 人次不及格都出自这一拨
  • 高三2班的中位数比均值高 5.5 分,是左偏:少数低分把均值往下拉。这时候均值代表不了「典型的学生」,中位数更合适
  • 这正好是前面分数段那张图里「两头多,中间少」的另一面。同一件事从占比和分布两个方向都看到了,结论才可靠