Python 数据处理进阶(二十一)相关性与分组对比:数据里藏着什么关系

写在前面

上一篇讲怎么保证数据干净,这一篇讲干净之后怎么「看出东西」。

汇总表只回答「多少」,回答不了「为什么」和「什么和什么有关」。而分析报告的价值,往往就在后半句:哪些指标同向变化、哪类组合表现最好、哪个群体明显偏离。

这篇用四个工具回答三类问题:corr 看数值关系、crosstab 看分类交叉、groupby 看分组差异、热力图看整体格局。

先确认示例数据在手边:

python 复制代码
import os
import mkdata

if not os.path.exists("data/sales.csv"):
    mkdata.main()
print("示例数据就绪:", os.path.exists("data/sales.csv"))

这段会检查第 1 篇生成的 data/ 目录在不在,不在就自动补一次;已经有数据时只会打印一行 示例数据就绪: True,不影响后面的结果。

一、corr:两个数值列是同向还是反向

corr() 默认算 Pearson 相关系数,取值 −1 到 1:接近 1 同向、接近 −1 反向、接近 0 没关系。

python 复制代码
import pandas as pd

df = pd.read_csv("data/sales.csv", encoding="utf-8-sig")

# 只保留数值列(文本列算不了相关)
num = df.select_dtypes(include="number")
print("数值列:", num.columns.tolist())
print("----")
print("Pearson 相关系数:")
print(num.corr().round(3).to_string())
print("----")
print("Spearman(秩相关,抗极端值):")
print(num.corr(method="spearman").round(3).to_string())
text 复制代码
数值列: ['数量', '单价', '金额']
----
Pearson 相关系数:
       数量     单价     金额
数量  1.000 -0.039  0.245
单价 -0.039  1.000  0.448
金额  0.245  0.448  1.000
----
Spearman(秩相关,抗极端值):
       数量    单价     金额
数量  1.000 -0.06  0.495
单价 -0.060  1.00  0.420
金额  0.495  0.42  1.000

两个参数要会用:

  • method="spearman":按排名算相关,不受极端值影响。数据里有 -599 这种脏值时,Spearman 比 Pearson 稳;
  • min_periods:参与计算的最小样本数,小样本别硬算。

corrwith 更省事------一张表对另一列算相关:

python 复制代码
import pandas as pd

df = pd.read_csv("data/sales.csv", encoding="utf-8-sig")
num = df.select_dtypes(include="number")
print("各列与金额的相关性:")
print(num.corrwith(num["金额"]).round(3).sort_values(ascending=False).to_string())
text 复制代码
各列与金额的相关性:
金额    1.000
单价    0.448
数量    0.245

二、crosstab:分类字段的交叉表

两张分类表交叉,看组合分布,crosstab 比 groupby 更直观。

python 复制代码
import pandas as pd

df = pd.read_csv("data/sales.csv", encoding="utf-8-sig")

# 城市 × 产品:每个组合有多少单
ct = pd.crosstab(df["城市"], df["产品"], margins=True, margins_name="合计")
print("订单数交叉表:")
print(ct.to_string())
print("----")
# normalize="index":每行归一化,看每个城市内部的产品结构
ct_pct = pd.crosstab(df["城市"], df["产品"], normalize="index").round(3)
print("按城市看产品结构(行占比):")
print(ct_pct.to_string())
text 复制代码
订单数交叉表:
产品  A-笔记本  B-鼠标  C-键盘  D-显示器  合计
城市                              
上海      0     2     2      0   4
北京      1     0     1      1   3
广州      0     2     1      0   3
杭州      2     1     0      2   5
合计      3     5     4      3  15
----
按城市看产品结构(行占比):
产品  A-笔记本   B-鼠标   C-键盘  D-显示器
城市                            
上海  0.000  0.500  0.500  0.000
北京  0.333  0.000  0.333  0.333
广州  0.000  0.667  0.333  0.000
杭州  0.400  0.200  0.000  0.400

normalize 的三个取值很好记:"index" 按行、"columns" 按列、"all" 按全表。分析结构差异时用 index,比较不同城市的偏好正合适。

三、groupby 分组对比:把「差异」量化

分组对比的关键不是算均值,而是同时给几个指标 + 排序 + 算占比,让差异一眼可见。

python 复制代码
import pandas as pd

df = pd.read_csv("data/sales.csv", encoding="utf-8-sig")
df["金额"] = df["金额"].fillna((df["数量"] * df["单价"]).round(2))
clean = df[(df["金额"] > 0)].drop_duplicates()

g = (
    clean.groupby("城市", dropna=False)
    .agg(订单数=("金额", "size"), 销量=("数量", "sum"),
         销售额=("金额", "sum"), 客单价=("金额", "mean"))
    .round(2)
)
g["销售额占比"] = (g["销售额"] / g["销售额"].sum() * 100).round(1)
g = g.sort_values("销售额", ascending=False)
print(g.to_string())
print("----")
print("最高与最低城市销售额差:", round(g["销售额"].max() - g["销售额"].min(), 2), "元")
text 复制代码
     订单数  销量     销售额     客单价  销售额占比
城市                                 
杭州     4  14  3404.1  851.02   54.1
北京     3   5  1425.9  475.30   22.7
上海     2  12   772.5  386.25   12.3
广州     2  10   535.4  267.70    8.5
NaN    1   3   149.7  149.70    2.4
----
最高与最低城市销售额差: 3254.4 元

一个实用习惯:分组结果一定加占比列。「杭州 6399 元」没人有感觉,「杭州占 67.6%」立刻就有结论了。

四、pd.cut 分段对比:连续变量怎么分组

金额、年龄、时长这类连续值,先分段再对比,比直接看原始值有洞察。

python 复制代码
import numpy as np
import pandas as pd

df = pd.read_csv("data/sales.csv", encoding="utf-8-sig")
df["金额"] = df["金额"].fillna((df["数量"] * df["单价"]).round(2))
clean = df[df["金额"] > 0].drop_duplicates()

clean["价格段"] = pd.cut(
    clean["单价"], bins=[0, 50, 100, 600], labels=["低价(<50)", "中价(50-100)", "高价(>100)"]
)
seg = clean.groupby("价格段", observed=True).agg(
    订单数=("金额", "size"), 销售额=("金额", "sum"), 客单价=("金额", "mean")
).round(2)
print(seg.to_string())
print("----")
print("各价格段订单占比:")
print((clean["价格段"].value_counts(normalize=True).sort_index() * 100).round(1).to_string())
text 复制代码
            订单数     销售额      客单价
价格段                             
低价(<50)       7   937.6   133.94
中价(50-100)    3  1157.0   385.67
高价(>100)      2  4193.0  2096.50
----
各价格段订单占比:
价格段
低价(<50)       58.3
中价(50-100)    25.0
高价(>100)      16.7

pd.cut 是按你给的边界 切(业务含义明确);pd.qcut 是按分位数切(每段样本数尽量相等)。前者适合汇报,后者适合建模。

五、画出来:相关系数热力图

数字看着不直观,画成热力图,哪里强哪里弱一眼就有感觉。

图 1:相关系数热力图。数量与金额 0.24、单价与金额 0.45------都不算强,说明销售额更多由「单价高」而不是「买得多」驱动。

python 复制代码
import os

import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd

plt.rcParams["font.sans-serif"] = ["SimHei"]
plt.rcParams["axes.unicode_minus"] = False

df = pd.read_csv("data/sales.csv", encoding="utf-8-sig")
num = df.select_dtypes(include="number")
corr = num.corr().round(2)

fig, ax = plt.subplots(figsize=(6.2, 5))
im = ax.imshow(corr, cmap="RdBu_r", vmin=-1, vmax=1)
ax.set_xticks(range(len(corr)))
ax.set_yticks(range(len(corr)))
ax.set_xticklabels(corr.columns, rotation=30)
ax.set_yticklabels(corr.index)
for i in range(len(corr)):
    for j in range(len(corr)):
        v = corr.iloc[i, j]
        ax.text(j, i, f"{v:.2f}", ha="center", va="center", fontsize=10,
                color="white" if abs(v) > 0.6 else "#1f2937")
ax.set_title("数值列相关系数热力图", fontsize=13, pad=12)
fig.colorbar(im, ax=ax, shrink=0.85, label="Pearson 相关系数")
fig.tight_layout()
os.makedirs("output", exist_ok=True)
fig.savefig("output/corr_heatmap.png", dpi=200)
plt.close(fig)
print("已保存 output/corr_heatmap.png")
print("相关系数最大的一对:", corr.where(~np.eye(len(corr), dtype=bool)).stack().idxmax())
text 复制代码
已保存 output/corr_heatmap.png
相关系数最大的一对: ('单价', '金额')

六、相关不等于因果:一个反例

假设你发现「冰淇淋销量」和「溺水人数」强相关(r≈0.9)。结论不是「吃冰淇淋导致溺水」,而是两者都受第三个因素------气温影响。

分析报告里最容易翻车的就是这一句。三个自检问题:

  1. 有没有第三个变量同时影响两者(混淆变量)?
  2. 因果关系方向对吗(A 导致 B,还是 B 导致 A)?
  3. 样本量够吗?小样本的高相关很容易是巧合。

常见问题

Q1:corr() 报 could not convert string to float。

表里有文本列。先 df.select_dtypes(include="number") 过滤;如果某个「数字列」其实是文本(带单位、空格),先 pd.to_numeric(errors="coerce")。

Q2:crosstab 结果里少了 NaN 那一行。

crosstab 默认丢弃缺失。想保留:pd.crosstab(df["城市"].fillna("未知"), df["产品"], dropna=False)。

Q3:热力图上中文全是方块。

没设中文字体。plt.rcParams["font.sans-serif"] = ["SimHei"],Mac 换 PingFang SC,并且要写在画图之前------这和第 11 篇是同一个坑。

小结

这一篇的四个工具各管一段:corr 管数值关系、crosstab 管分类交叉、groupby + 占比 管分组差异、pd.cut 管连续值分段。把它们组合起来,一张表就能回答「谁和谁有关、哪类最好、差多少」。但结论出口前一定要过一遍「相关不等于因果」的三问------这比任何代码都重要。

下一篇进入第二批的实战篇:用一份模拟数据,把用户行为分析从指标定义做到结论输出。

相关推荐
Gent_倪1 小时前
Python 语句梳理
python
u1301301 小时前
AI 日报(2026年11月1日)
人工智能
FL16238631291 小时前
芒果果实病害图像分类数据集862张5类别
人工智能·分类·数据挖掘
roman_日积跬步-终至千里1 小时前
数仓方法论(五):历史与增量——让变化进入数仓而不丢失解释
大数据
freejackman1 小时前
【架构篇】双遍历 + 多栈协同:手撸 SQL 血缘引擎的架构思考
java·大数据·数据库·hive·sql·架构·spark
Zhao-Jichao1 小时前
AI Prompting for Everyone 课程笔记 - P06 模型提示词对比 AI Model Prompt Comparison
人工智能·笔记·prompt
武子康1 小时前
MultiNet 只听到一次命令,为什么连续触发?
人工智能·后端·agent
每天都要写算法(努力版)1 小时前
【行业前沿报告】AutoML / AutoResearch / RSI
人工智能·agent·rsi
全栈探索者1 小时前
给视频配一段完整 BGM:一次生成还是分段拼?
人工智能·comfyui·音频生成·stable audio