跳转到内容

性能、内存及 SQL 对照

计算机基础 · pandas

前置知识:shift、rolling、resample 与时间对齐 · merge、concat 与连接检查

本章目录

本章学习目标

  • 在保持语义前提下优化处理
  • 测量深层内存与类别转换
  • 逐项对照 SQL 和 pandas 的差异

先正确,再测量瓶颈

性能改进必须建立在正确结果和代表性输入上。常见路径是少读列、减少中间表、使用内置向量化与分组运算、一次 concat,以及对有限类别使用 category。不要因为一段代码看起来没有循环就宣布它更快;某些字符串操作仍有较大开销,数据量、类型和缺失比例也会影响速度。

计时应多次运行并区分读取、计算和写出,首次导入或缓存不应该混进稳定计算耗时。小教学表适合验证算法语义,不适合给出生产性能排名。本章输出内存占用而不把绝对耗时作为验收条件,避免同一脚本在不同机器上产生误导性的好坏判断。

内存与类型选择

memory_usage(deep=True) 会更充分地考虑 object 或字符串内容,默认浅层统计可能低估占用。重复状态转成 category 通常节省空间,但高基数唯一字符串未必获益;应在转换前后测量,并核对值仍然一致。整数可以在取值范围允许时降为较窄类型,但溢出会破坏金额或计数。浮点降精度必须符合误差容忍,财务金额不能为了内存改成低精度近似。

大表读取时用 usecols 和显式 dtype,分块处理可做逐块求和等可合并统计。求整体中位数、精确去重或任意全局连接通常不能简单分块后再平均结果,需要设计额外状态或改用数据库。先说清统计是否可合并,再选择分块策略。

SQL 与 pandas 语义表

SQL COUNT(*) 对应 size,COUNT(col) 对应 count;SQL GROUP BY 会保留 NULL 组,而 pandas 需显式 dropna=False。SUM 在无有效数值时的行为需要检查,pandas 可用 min_count=1 保留缺失。SQL 普通等号连接不匹配 NULL,pandas merge 可能匹配双方空键;需要显式空键策略。

SQL 表本身没有自然顺序,必须 ORDER BY;pandas 表有当前行顺序,但经过聚合或连接也不应假定某种业务排序。日期比较应统一字段类型、业务时区与左闭右开边界。MySQL DECIMAL 是精确小数,pandas 浮点金额需要转换为 Decimal 或整数分;若先在不同精度下计算,最后字符串相同也不能证明逐笔一致。

小数据与独立基线

脚本把配套订单净额转换成整数分,筛选 paid/refunded,保留空用户分组。然后使用 groupby 得到用户净额,再用 itertuples 独立累计到字典,比较两种计算。这里保留一个逐行实现作为清晰的正确性基线,并非鼓励生产中无条件逐行处理。

用键排序后逐项比较,才能看出连接错位或分组丢失。浮点指标使用明确的容忍值,不要默认精确相等;整数金额则要求精确相等。对于 SQL 对照,还要在 MySQL 执行同口径查询,对照数据库输出。本章仅验证 pandas 与独立 Python 基线,不能冒充数据库执行验证。

优化反例与工作流程

最常见反例是使用 iterrows 逐行更新原表、循环 concat、在每行反复解析日期、无检查地把全部列转成 category。另一类错误是为了快而提前删除空键或去重,结果速度提高但业务对象改变。优化后重跑同一组键、行数、总额和缺失断言,确认语义不变。

若数据已经存储在数据库,过滤和聚合通常可以在数据库完成,pandas 接收较小结果再绘图;不要把巨大事实表全部拉到内存后只计算一个总和。任务说明应记录输入大小、所用版本、关键口径、测量范围及输出位置,让下一位同事能够复现结果。升级版本时先做结果回归,再讨论性能变化。

版本与复现约定

本课程 pandas 示例以 2.3.3 为验证基线,页面运行记录给出实际版本。pandas 3.x 默认使用 Copy-on-Write:从表中取出的对象发生修改时,不据此假定原表同步变化。跨版本都应使用一次完整的 loc 赋值表达式;需要独立工作表时显式 copy。阅读网上的旧示例时,先核对版本、输入类型和索引,再判断结果能否复现。

完整示例与实际输出

from pathlib import Path
import pandas as pd
import numpy as np
from _shared import load
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "outputs"
OUT.mkdir(exist_ok=True)
from decimal import Decimal
orders=load("orders").copy()
orders["net_cents"]=[int((Decimal(str(a))-Decimal(str(r)))*100) for a,r in zip(orders["amount"],orders["refund_amount"])]
paid=orders.loc[orders["status"].isin(["paid","refunded"])]
vector=paid.groupby("user_id",dropna=False)["net_cents"].sum()
manual={}
for r in paid.itertuples(index=False):
    k="NULL" if pd.isna(r.user_id) else str(int(r.user_id))
    manual[k]=manual.get(k,0)+r.net_cents
actual={"NULL" if pd.isna(k) else str(int(k)):int(v) for k,v in vector.items()}
assert actual==manual
before=orders.memory_usage(deep=True).sum()
compact=orders.copy()
compact["status"]=compact["status"].astype("category")
after=compact.memory_usage(deep=True).sum()
assert compact["status"].astype("string").equals(orders["status"].astype("string"))
print("内存字节 / 转类别:",int(before),int(after))
print("分组结果与独立逐行累计一致:",actual)
print("全行数 / 已知用户数:",len(paid),paid["user_id"].count())

实际运行通过CPU Python execution.

内存字节 / 转类别: 9842 6948
分组结果与独立逐行累计一致: {'1': 14400, '2': 72000, '3': 2500, '4': 14400, '5': 72000, '6': 2500, '7': 12000, '8': 72000, '9': 1875, '10': 14400, '11': 60000, '12': 1250, '13': 9600, '14': 48000, '15': 2500, '16': 9600, '17': 48000, '18': 1250, '19': 9600, '20': 36000, '21': 1250, '22': 7200, '23': 48000, '24': 2500, 'NULL': 1250}
全行数 / 已知用户数: 54 53

五道自测:先回答,再核对

01 · 类别类型一定省内存吗?

查看提示

取值重复率。

查看参考答案

不一定;高基数可能增加字典开销,应 deep=True 实测。

02 · SQL COUNT(*) 对应什么?

查看提示

计不计空列。

查看参考答案

groupby.size;count只算非缺失值。

03 · 分块均值如何合并?

查看提示

每块大小不同。

查看参考答案

保存每块有效值总和与有效数,最终总和除总数,不直接平均块均值。

04 · 优化后首先比较什么?

查看提示

业务语义。

查看参考答案

键、行数、总额、缺失与边界结果,确认同口径再测性能。

05 · 本章 Python 基线能代替 MySQL 验证吗?

查看提示

执行引擎不同。

查看参考答案

不能,只证明这两个 Python 算法一致;MySQL 需实际执行。

两项动手任务

任务 1 · 比较类别内存并验证金额汇总与独立基线一致。

提示

先锁定业务口径,再看内存。

完整参考解答与检查方式
from pathlib import Path
import pandas as pd
import numpy as np
from _shared import load
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "outputs"
OUT.mkdir(exist_ok=True)
from decimal import Decimal
orders=load("orders").copy()
orders["net_cents"]=[int((Decimal(str(a))-Decimal(str(r)))*100) for a,r in zip(orders["amount"],orders["refund_amount"])]
paid=orders.loc[orders["status"].isin(["paid","refunded"])]
vector=paid.groupby("user_id",dropna=False)["net_cents"].sum()
manual={}
for r in paid.itertuples(index=False):
    k="NULL" if pd.isna(r.user_id) else str(int(r.user_id))
    manual[k]=manual.get(k,0)+r.net_cents
actual={"NULL" if pd.isna(k) else str(int(k)):int(v) for k,v in vector.items()}
assert actual==manual
before=orders.memory_usage(deep=True).sum()
compact=orders.copy()
compact["status"]=compact["status"].astype("category")
after=compact.memory_usage(deep=True).sum()
assert compact["status"].astype("string").equals(orders["status"].astype("string"))
print("内存字节 / 转类别:",int(before),int(after))
print("分组结果与独立逐行累计一致:",actual)
print("全行数 / 已知用户数:",len(paid),paid["user_id"].count())

任务 2 · 分块合并含缺失数据的均值,比较全体结果。

提示

累计有效总和和有效数。

完整参考解答与检查方式
from pathlib import Path
import pandas as pd
import numpy as np
from _shared import load
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "outputs"
OUT.mkdir(exist_ok=True)
s=pd.Series([1.,None,5.,9.,2.])
chunks=[s.iloc[:2],s.iloc[2:]]
total=sum(float(c.sum()) for c in chunks)
n=sum(int(c.count()) for c in chunks)
mean=total/n if n else np.nan
assert mean==s.mean()==4.25
print("全体均值:",mean)

官方资料与掌握状态

完成两项任务后,按独立完成程度记录。状态会尽量保存在当前浏览器,可导出备份。

动手任务记录

掌握状态与复习

关掉资料独立完成后记 A;提示后完成记 B;需要重学记 C。

记录尽量保存在当前浏览器,建议定期导出备份。