联合分析:数值和占比、外延和内涵
单维度分布只数每一组有多少。联合分析要拿一个特征当主体,看另一个特征在它下面怎么分布:以班级为主体看分数段,比较的是班和班之间的差别;以分数段为主体看班级,比较的是每个分数段由哪些班组成。
聚合函数也不再只是 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,否则图会糊成一片。
总分能加,但加出来的数不能比
总分是外延量,技术上可以加总,也可以堆叠。那它能不能用来比较各班?
| 班级 | 总分 | 人次 | 均分 |
|---|---|---|---|
| 高一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)
- 目录页里说「外延量加总也不见得有意义」,这里就是一个例子:总分 = 均分 × 人次,把班级规模混了进来。它唯一的用处是当均分的分子
均分只能并排放,不能叠
均分是内涵量,两个班的均分相加没有意义,所以不能画成堆叠柱,只能用热力图或者分组柱状图:
| 班级 | 语文 | 数学 | 英语 |
|---|---|---|---|
| 高一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 分,是左偏:少数低分把均值往下拉。这时候均值代表不了「典型的学生」,中位数更合适
- 这正好是前面分数段那张图里「两头多,中间少」的另一面。同一件事从占比和分布两个方向都看到了,结论才可靠