---
description: "联合分析：不及格人次最多的班，班内占比反而不是最高；总分为什么不能比；年级均分为什么要加权；均分一样，分布可以完全不同"
---

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

[单维度分布](distribution.md)只数每一组有多少。联合分析要**拿一个特征当主体，看另一个特征在它下面怎么分布**：以班级为主体看分数段，比较的是班和班之间的差别；以分数段为主体看班级，比较的是每个分数段由哪些班组成。

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

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

```python
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 里已按年级排好
```

## 人次最多的班，占比不一定最高

### 以班级为主体

```python
pt = df.pivot_table(index="班级", columns="分数段", aggfunc="size", observed=False)
share = pt.div(pt.sum(axis=1), axis=0)  # 每一行除以行合计 = 班内占比
```

=== "人次 pt"

    | 班级 | 优 | 良 | 及格 | 不及格 |
    |:--|--:|--:|--:|--:|
    | 高一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 |

=== "班内占比 share"

    | 班级 | 优 | 良 | 及格 | 不及格 |
    |:--|--:|--:|--:|--:|
    | 高一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% |

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

```echarts
{
  "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"}}]
}
```

然后只看「不及格」这一类：柱子是不及格人次（左轴），折线是它在班内的占比（右轴）：

```echarts
{
  "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` 对调，主体就换成了分数段：

```python
pt = df.pivot_table(index="分数段", columns="班级", aggfunc="size", observed=False)
share = pt.div(pt.sum(axis=1), axis=0)  # 每个分数段内部，各班占几成
```

=== "人次 pt"

    | 分数段 | 高一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 |

=== "段内占比 share"

    | 分数段 | 高一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% |

```echarts
{
  "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班：柱子是它在每个分数段里的人次，折线是它在这个分数段里占几成：

```echarts
{
  "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 表转置一下，问题就从「这个班的学生都在哪个分数段」变成了「这个分数段的学生都来自哪个班」

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

## 总分能加，但加出来的数不能比

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

```python
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 |

```echarts
{
  "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）
- [目录页](index.md)里说「外延量加总也不见得有意义」，这里就是一个例子：总分 = 均分 × 人次，把班级规模混了进来。它唯一的用处是当均分的分子

## 均分只能并排放，不能叠

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

```python
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 |

=== "热力图"

    ```echarts
    {
      "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]]}]
    }
    ```

=== "分组柱状图"

    ```echarts
    {
      "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）；分组柱状图适合比较同一个班内部的三科
- 每个班都是数学最低，这跟单维度分布里「数学及格最多、优最少」对得上

### 年级均分：先加权，再平均

内涵量要往上汇总（班 → 年级），不能直接把各班均分再平均一次：

```python
加权 = 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` 换成一个列表：

```python
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 |

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

```echarts
{
  "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 分，是左偏：少数低分把均值往下拉。这时候均值代表不了「典型的学生」，中位数更合适
- 这正好是前面分数段那张图里「两头多，中间少」的另一面。同一件事从占比和分布两个方向都看到了，结论才可靠
