系列名:《从零搭建你的 A 股量化系统》| 专栏 S2 Python 工具链 | 第 5 篇 / 共 14 篇
标签:#量化投资 #Pandas #性能优化 #向量化 #dtype #iterrows #量化回测 #Python #实战 #A股 #散户 #避坑
上篇 S2-016 我们把 Pandas 的 30 个量化必备操作讲完了,你现在已经能「分组、滚动、错位、拼表、不造假」。但那些例子全跑在一只沪深300ETF、1600 行的小数据上——在你机器上几乎瞬时出结果,你根本感觉不到写法好坏。
真正分生死的时刻,是数据涨起来之后。 当你从「一只 ETF」变成「全 A 股 5000 只 × 约 8 年日线 ≈ 1000 万行」,前面那些写法里至少有 7 个会让你「跑一下午没出结果、内存爆红、最后 Kernel Restarted」。更要命的是:同样的代码,在 10 万行时跑得飞快,你根本发现不了它是错的——等到数据涨 100 倍,它才突然变成 100 倍慢。这就是本篇标题里那个「慢 100 倍」的真相。
配套脚本 src/s2_017_pandas_perf.py 用一段 A 股风格的合成面板(5000 只代码、固定随机种子、非真实行情)把下面 7 个坑的「慢写法 vs 快写法」逐一计时跑通,所有数字都是本机实测。本篇你看到的加速比,全部来自真实运行。
📌 你将学到
- 7 个性能杀手:iterrows 逐行、循环里反复 append/concat、隐式拷贝与链式赋值、apply(axis=1) 逐元素、类型不降级、merge 写进循环、读 CSV 不设 dtype;
- 每个坑的正确替代:向量化、np.where、np.digitize、groupby 一次聚合、类型降级、category 类型、读入即瘦身;
- 一个比「快」更重要的认知:有些优化不为省这一行代码的耗时,而为省后续所有代码的耗时 + 省内存;
- 一组真实跑分:同样的活,慢写法比快写法慢 150 倍到 1200 倍;
- 一个线性放大演示:同样代码,行数 ×100 → 耗时 ×100,1000 万行时一个最基础的操作就要跑 ~6 分钟。
📖 前置知识
- 已按 S2-015 / S2-016 装好 ashare 环境、pip install pandas numpy,理解 DataFrame、groupby、rolling、merge;
- 看过 S2-014《NumPy 向量化》最好——本篇 7 个坑里有 5 个的「正确写法」本质就是「把活甩给 C 扩展一次性批量算完」,和 S2-014 的「能向量化就别循环」一脉相承;
- 会最基础的 Python:函数、循环、dict。本篇不要求任何金融数学基础。
先说结论
能向量化就别循环、能一次算就别逐行、能瘦身就别抱着胖表到处跑。 这三句话是 S2-014 向量化思想的落地。但本篇有一个比「快」更重要一万倍的点:
💡 性能优化的目标不一定是「这一行更快」,而是「后续所有操作更省 + 内存不爆」。
比如本篇坑⑦「读 CSV 不设 dtype」:设了 dtype 之后,读入耗时反而略慢一点点(因为多了类型转换),但读进来的表从 107 MB 瘦到 30 MB,后续每个 groupby/merge 都跟着快——而且你不会再因为内存爆掉而崩。这种「不为这一行省时间、为后面省一整片时间」的取舍,才是真实工程里的日常。
下面 7 个坑,每一个都附「慢写法(❌)→ 快写法(✅)+ 本机实测加速比」。数字会因机器不同略有浮动,但量级是稳定的。
坑① iterrows 逐行循环 —— 量化新手的头号性能杀手
什么时候会踩:你想「对每一行算个指标」——比如算每日收益率、算 (高-低)/收、给每行打标签。直觉写法是 for idx, row in df.iterrows(): …。
iterrows 会为每一行都构造一个 Series 对象、做一堆类型推断和装箱,等于把 Python 解释器叫了 N 次。100 万行就是 100 万次。这正是 S2-014 反复警告的「在 Python 层循环」。
# ❌ 慢写法:逐行 Python 循环
def p1_slow(df):
prev = None
rets = []
for _, r in df.iterrows(): # 每一行都进 Python
rets.append(0.0 if prev is None else (r["close"] – prev) / prev)
prev = r["close"]
out = df.copy()
out["ret"] = rets
return out
# ✅ 快写法:向量化,一次算完
def p1_fast(df):
out = df.copy()
out["ret"] = (df["close"] – df["close"].shift()) / df["close"].shift()
return out
本机实测(N = 100,000 行):
慢 (iterrows): 3.68 s
快 (向量化) : 3.56 ms
加速比 : 1034x
🚨 1034 倍。 也就是说你喝口水的功夫,向量化版已经把活干完 1000 遍了。iterrows 在量化里几乎永远不该出现——任何「对每行算」的需求,先想三秒能不能写成一列运算。实在绕不开(极少数情况),至少用 itertuples(比 iterrows 快约 10~50 倍),但首选永远是向量化。
坑② 循环里反复 append / concat —— O(N²) 的沉默炸弹
什么时候会踩:你从 API / 文件一批批取数据,边取边拼:result = pd.concat([result, new_row], ignore_index=True)。或者 result = result.append(new_row)(已废弃)。
问题在于:concat 每次都要把已有结果完整拷贝一份再接上新行。第 1 次拷 1 行,第 2 次拷 2 行……第 N 次拷 N 行,总拷贝量是 1+2+…+N = O(N²)。5000 条记录就够它慢出 1000 倍。
# ❌ 慢写法:在循环里反复 concat(O(N²) 拷贝)
def p2_slow(records):
result = pd.DataFrame()
for rec in records:
result = pd.concat([result, pd.DataFrame([rec])], ignore_index=True)
return result
# ✅ 快写法:先收集成 list,最后一次性构造
def p2_fast(records):
return pd.DataFrame(records)
本机实测(N = 5,000 条记录):
慢 (循环 concat): 1.49 s
快 (一次构造) : 1.54 ms
加速比 : 966x
💡 记住铁律:永远先把要拼的行攒进一个 Python list(或 list of dict),最后 pd.DataFrame(list) 一次成型。 list.append 是 O(1) 的,而 DataFrame.concat 在循环里是 O(N²) 的。差出来的就是这近 1000 倍。
坑③ 隐式拷贝与链式赋值 —— 又慢「又偷偷写不进去」
这个坑有两副面孔,一副是性能(反复对大表做切片拷贝),一副是正确性(链式赋值悄悄失败)。量化里两个都致命。
性能面:你写了个循环,对每个阈值都对整张表 df[mask].copy() 再赋值——每次 copy() 都把百万行完整复制一遍,循环 200 次就是 200 次全表拷贝。
正确性面:df[df.volume > thr]["bucket"] = k 这种「链式索引赋值」,pandas 往往会静默失败(触发 SettingWithCopyWarning,且新列根本没写进去)。你以为写上了,其实没有,后面算出来全是 NaN——回测却“正常”跑完,这种 bug 比报错更可怕。
# ❌ 慢 + 错写法:链式赋值(通常写入失败)+ 反复整表拷贝
def p3_slow(df, thresholds):
out = df.copy()
for k, thr in enumerate(thresholds):
sub = out[out["volume"] > thr].copy() # 每次都拷整张表
sub["bucket"] = k
_ = sub
# ✅ 快写法:np.digitize 一次向量化分箱
def p3_fast(df, thresholds):
out = df.copy()
out["bucket"] = np.digitize(out["volume"].to_numpy(), thresholds)
return out
本机实测(N = 200,000 行 × 200 个阈值):
慢 (反复整表拷贝): 2.16 s
快 (digitize 一次): 14.79 ms
加速比 : 146x
# 链式赋值现场演示:
demo[demo["volume"] > 1_000_000]["flag"] = 1
print("链式赋值后 flag 列是否存在:", "flag" in demo.columns) # -> False(写入失败!)
🚨 当场验证:上面那段链式赋值跑完,flag 列根本不存在——你的「特征」悄无声息地丢了。正确姿势永远是用 .loc 一次性赋值:
df.loc[df["volume"] > 1_000_000, "flag"] = 1 # ✅ 一次写进去,不拷贝
需要「按条件改一列」时,np.where / df.loc[mask, col] 永远优先于任何链式写法。
坑④ apply(axis=1) 逐元素 —— 披着「向量化」外衣的循环
什么时候会踩:你想写个「带 if 的判断」,比如「放量日用收盘价,否则打五折」,顺手写成 df.apply(lambda r: … if r.volume > x else …, axis=1)。axis=1 意味着每一行调用一次 Python 函数——本质和 iterrows 一样慢,只是看起来「高级」了点。
# ❌ 慢写法:apply 逐行(每个 row 一次 Python 调用)
def p4_slow(df):
out = df.copy()
out["adj"] = df.apply(
lambda r: r["close"] * (1.0 if r["volume"] > 1_000_000 else 0.5), axis=1)
return out
# ✅ 快写法:np.where 向量化分支
def p4_fast(df):
out = df.copy()
out["adj"] = np.where(df["volume"] > 1_000_000,
df["close"], df["close"] * 0.5)
return out
本机实测(N = 200,000 行):
慢 (apply 行级): 1.12 s
快 (np.where) : 6.35 ms
加速比 : 176x
💡 经验法则:能用 np.where / 四则运算 / 比较运算一行写完的,绝不用 apply(axis=1)。 如果逻辑实在复杂、apply 躲不掉,至少用 apply 一个返回 Series 的向量化函数到整列(配合 raw=True),或改用 map(对 category/object 列做字典映射极快)。axis=1 是性能红灯。
坑⑤ 类型不降级 —— 内存翻倍、缓存失效、悄悄变慢
什么时候会踩:pd.read_csv 读进来的表,默认所有数字都是 float64、所有字符串都是 object、所有整数都是 int64。一只 1000 万行的表,光这一项就能吃掉 150 MB+,而其中大部分精度你根本用不上。
量化数据的特点是:价格用 float32 足够(6 位有效数字,对股价完全够),成交量 int32 够(最大 21 亿),股票代码就那 5000 个固定值 → category 类型省到极致,日期字符串 → datetime64。
# ✅ 快写法:读入后立刻降级
def p5_downcast(df):
d = df.copy()
for c in ["open", "high", "low", "close"]:
d[c] = d[c].astype("float32") # float64 -> float32(内存直接砍半)
d["volume"] = d["volume"].astype("int32") # int64 -> int32
d["code"] = d["code"].astype("category") # 5000 个字符串 -> 枚举(groupby 也更快)
d["date"] = pd.to_datetime(d["date"]) # object 字符串 -> datetime64
return d
本机实测(N = 1,000,000 行):
默认 dtype 内存: 154.0 MB
降级后 dtype 内存: 30.0 MB
省下内存 : 80.5% (约 5.1x 更省)
📌 内存省 80%,意味着什么? 一是同样机器能放下 5 倍的数据;二是 category 类型做 groupby 比 object 字符串快好几倍(本篇坑⑦的下游 groupby 就快了 3 倍);三是数据更小、更能塞进 CPU 缓存,所有运算都跟着快。类型降级是「花一次小代价、赚一路便宜」的典型。 进阶可上 pd.eval / query(numexpr 后端,对大浮点表有额外加速),但降级是地基。
坑⑥ 循环里反复 merge —— 越合越宽、越合越慢
什么时候会踩:你想「给每只股票贴一个标签列」,于是 for code in codes: df = df.merge(该股票的小表, on="code")。每次 merge 都要扫一遍全表、按 key 重排,循环 50 次就是 50 遍全表扫描;而且每次还往表上多拼一列,表越来越宽、拷贝越来越多。
# ❌ 慢写法:每只股票 merge 一次(50 只 = 50 次全表扫描)
def p6_slow(df, codes):
merged = df.copy()
for code in codes:
tmp = df[df["code"] == code][["code", "close"]].rename(columns={"close": f"c_{code}"})
merged = merged.merge(tmp, on="code", how="left")
return merged
# ✅ 快写法:先 groupby 聚合成一张小查找表,再 merge 一次
def p6_fast(df, codes):
lookup = df.groupby("code", observed=True)["close"].last().rename("last_close").reset_index()
return df.merge(lookup, on="code", how="left")
本机实测(N = 100,000 行 × 50 只股票):
慢 (循环 merge): 25.91 s
快 (groupby+一次merge): 20.97 ms
加速比 : 1235x
🚨 1235 倍。 而且这才 50 只股票——真实 A 股有 5000 只,按比例就是 100 倍以上更夸张,基本等于「永远跑不完」。正确范式永远是把「逐标的操作」先 groupby/聚合,再「一次 merge」回主表。凡是看到循环里有 merge/join,立刻警觉。
坑⑦ read_csv 不设 dtype —— 入口处就埋下慢根
什么时候会踩:pd.read_csv("ashare_1m.csv")——啥参数都不给。pandas 会:① 对每一列做类型推断(慢,且常把本该是 category 的代码推断成 object);② 日期当成字符串存成 object(后面用还要再转);③ 把所有数字按 float64/int64 存(胖)。读进来是个 100 MB+ 的胖表,后续每个操作都背着这个包袱。
# ❌ 慢根源:裸读,全默认
df_slow = pd.read_csv("big.csv")
# ✅ 快写法:读入即瘦身(dtype + parse_dates)
fast_dtype = {"code": "category", "open": "float32", "high": "float32",
"low": "float32", "close": "float32", "volume": "int32"}
df_fast = pd.read_csv("big.csv", dtype=fast_dtype, parse_dates=["date"])
本机实测(CSV 100 MB,N = 1,000,000 行):
慢 (裸读,全默认): 0.947 s 读后内存 107.0 MB
快 (设 dtype+parse_dates): 1.186 s 读后内存 30.0 MB
读入耗时: 相近甚至略慢(多了类型转换)|但内存省 71.9%
下游 groupby 对比: 胖表 20.5 ms vs 瘦表 7.3 ms(category 键快 3x)
💡 这里就是开头那句话的现场:设了 dtype,读入反而慢了 0.2 秒,但内存从 107 MB 砍到 30 MB,后续 groupby 快 3 倍,且不会内存爆。 这 0.2 秒换来了「后面所有操作都便宜 + 不崩」,稳赚。再补一刀:真实 A 股 CSV 往往有很多字符串列,设了 dtype 还能省掉昂贵的类型推断,此时读入本身也会更快。usecols=[…] 还能顺手丢掉用不上的列,进一步瘦身。
重头戏:10 万行 → 1000 万行,慢 100 倍的真相
前面每个坑都是在「固定规模」上测的加速比。但本篇真正想让你刻进骨头里的,是放大效应:慢写法的耗时随行数线性增长,快写法也是线性、但常数小到可以忽略。于是——
同样一段「慢代码」,数据从 10 万行涨到 1000 万行(×100),它的耗时也差不多 ×100。
用坑①的 iterrows 写法(最典型)在本机实测两种规模:
N= 100,000 行 | 慢写法 3.70s | 快写法 2.94ms | 慢/快 1256x
N= 1,000,000 行 | 慢写法 37.54s | 快写法 30.38ms | 慢/快 1236x
10万→100万:慢写法 10.2x(近似线性 ×10)
推算 10万→1000万:慢写法 ≈ ×100(行数 ×100 = 耗时 ×100)
算一笔账让你后背发凉:
- 在 10 万行,这段最基础的「算收益率」用 iterrows 要 3.7 秒——你根本不觉得它慢;
- 到 1000 万行(全 A 股约 8 年日线),同一个操作要 ≈ 375 秒,约 6.3 分钟;
- 而向量化写法在 1000 万行依旧是 亚秒级(0.3 秒量级)。
🚨 6 分钟,只是「算一个收益率列」。真实研究里你要算几十个因子、回测几千次、每个因子都这么写——一下午就交代在里面了,还很可能中途内存爆掉直接崩。 这就是「在 10 万行时察觉不到、到 1000 万行才爆炸」的坑最阴险的地方:它不是报错,它是慢慢把你拖死。
所以性能优化的正确心态不是「现在小数据跑得动就不管」,而是:从第一行代码起就按 1000 万行的标准写。 而这 7 个坑的正确写法,在 10 万行和 1000 万行下都是同一个写法——一次写对,终身受益。
7 个坑 · 速查表(建议截图保存)
每次写完数据处理代码,逐条过一遍:
| ① | 逐行循环 | df.iterrows() | 向量化一列运算 | 1034x |
| ② | 循环里拼表 | 循环 concat/append | 攒 list 后一次 DataFrame | 966x |
| ③ | 隐式拷贝/链式赋值 | df[mask]["col"]=x + .copy() | .loc[mask, col]=x + np.digitize | 146x |
| ④ | 逐元素 apply | apply(axis=1) | np.where / map / 向量化 | 176x |
| ⑤ | 类型不降级 | 默认 float64/object | float32/int32/category | 内存省 80% |
| ⑥ | 循环里 merge | for 里反复 merge | groupby 聚合后一次 merge | 1235x |
| ⑦ | 读 CSV 裸读 | read_csv(path) 无参数 | 设 dtype+parse_dates+usecols | 内存省 72% + 下游 3x |
💡 看到 iterrows / apply(axis=1) / 循环里的 concat / 循环里的 merge / 没设 dtype 的 read_csv ——这五个词任何一个出现,立刻停下来想「能不能向量化 / 能不能一次做完」。
本篇小结 & 下篇预告
小结:本篇用一段 A 股风格合成面板(5000 只代码、固定随机种子、非真实行情),把 Pandas 里最容易导致「数据一大就崩」的 7 个坑逐一枪毙,并附本机实测跑分:
- 逐行类(① iterrows / ④ apply):本质都是在 Python 层循环,加速 176x~1034x;
- 循环拼接类(② append / ⑥ merge):O(N²) 或反复全表扫描,加速 966x~1235x;
- 拷贝/类型类(③ 拷贝 / ⑤ dtype):内存与隐式拷贝双输,内存最高省 80%;
- 入口类(⑦ read_csv):读入即瘦身,内存省 72% 且下游 groupby 快 3x。
最关键的认知有两条:① 能向量化就别循环、能一次算就别逐行;② 有些优化不为省这一行省时间,而为省后续所有操作的时间 + 省内存。 配合结尾的「10万→1000万 线性放大」演示——慢写法耗时随行数 ×100,快写法始终亚秒级——你应该已经明白:从第一行代码起就按 1000 万行的标准写,是量化工程的基本修养。
下一篇预告:专栏 S2 第六篇《Polars 入门:量化研究该不该换掉 Pandas(实测对比)》(S2-018,难度 ★★,【码】【图】)。你可能会问:既然 Pandas 这么多坑,是不是该直接换 Polars?下一篇我会用同一份 1000 万行数据,把 Pandas 和 Polars 在「读入 / groupby / 滚动 / 过滤」四个场景实测对比,告诉你哪些场景 Polars 快 5~20 倍、哪些场景 Pandas 生态更香、散户到底要不要换。准备好 ashare 环境,我们下篇见。
附:本篇可运行脚本
完整可复现的跑分脚本在 src/s2_017_pandas_perf.py,运行:
python src/s2_017_pandas_perf.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
s2_017_pandas_perf.py —— Pandas 性能优化:7 个让代码「慢 100 倍」的坑(真实跑分)
运行: python src/s2_017_pandas_perf.py
前置: pip install pandas numpy
说明: 本篇(S2-017)配套脚本。所有数据均为「A股风格的合成面板」(固定随机种子,
非真实行情),用于演示从 10 万行到 1000 万行的性能放大效应。
每个坑都给出「慢写法 slow」与「快写法 fast」的真实计时 + 内存对比,
并在结尾演示「同样代码,行数 ×100 → 耗时 ×100」的线性放大。
"""
from __future__ import annotations
import io
import os
import tempfile
import time
import numpy as np
import pandas as pd
SEED = 7
TRADING = 242
def banner(title: str) –> None:
print("\\n" + "=" * 68)
print(" " + title)
print("=" * 68)
def timeit(func, *args, n_repeat: int = 1, **kwargs):
"""返回 (best_seconds, result)。快的方法重复取最小值减小抖动。"""
best = float("inf")
res = None
for _ in range(n_repeat):
t0 = time.perf_counter()
res = func(*args, **kwargs)
t1 = time.perf_counter()
best = min(best, t1 – t0)
return best, res
# —————————– 数据生成 —————————–
def make_panel(n_rows: int, n_codes: int = 500, seed: int = SEED) –> pd.DataFrame:
"""生成 A股风格长表:date(字符串) / code(字符串) / 开高低收 / 成交量。
默认 dtype 全是「最胖」的:浮点 float64、字符串 object、整数 int64 —— 正是新手读 CSV 后的样子。"""
rng = np.random.default_rng(seed)
codes = [f"{c:06d}" for c in range(n_codes)]
code_arr = rng.choice(codes, size=n_rows)
base = pd.Timestamp("2006-01-01")
# 日期在合理范围内循环(最多 5000 个交易日≈20 年),避免 int64 时间溢出
MAXDAYS = 5000
offsets = np.arange(n_rows) % MAXDAYS
dates = (base + pd.to_timedelta(offsets, unit="D")).strftime("%Y-%m-%d")
# 用平稳的独立价格(O(10)),避免 100 万步随机游走累积溢出 float 范围
close = 10.0 * np.exp(rng.normal(0, 0.5, n_rows))
high = close * (1 + np.abs(rng.normal(0, 0.01, n_rows)))
low = close * (1 – np.abs(rng.normal(0, 0.01, n_rows)))
open_ = close * (1 + rng.normal(0, 0.005, n_rows))
volume = rng.integers(int(1e5), int(5e6), n_rows).astype("int64")
return pd.DataFrame({
"date": dates, # 默认读进来是 object 字符串
"code": code_arr, # 默认 object 字符串
"open": open_.astype("float64"),
"high": high.astype("float64"),
"low": low.astype("float64"),
"close": close.astype("float64"),
"volume": volume,
})
# ============================ 坑 ① iterrows 逐行 ============================
def p1_slow(df: pd.DataFrame) –> pd.DataFrame:
prev = None
rets = []
for _, r in df.iterrows(): # ❌ 逐行 Python 循环
rets.append(0.0 if prev is None else (r["close"] – prev) / prev)
prev = r["close"]
out = df.copy()
out["ret"] = rets
return out
def p1_fast(df: pd.DataFrame) –> pd.DataFrame:
out = df.copy()
out["ret"] = (df["close"] – df["close"].shift()) / df["close"].shift() # ✅ 向量化
return out
# ============================ 坑 ② 循环里反复 append ============================
def p2_slow(records: list[dict]) –> pd.DataFrame:
result = pd.DataFrame() # ❌ 在循环里反复 concat
for rec in records:
result = pd.concat([result, pd.DataFrame([rec])], ignore_index=True)
return result
def p2_fast(records: list[dict]) –> pd.DataFrame:
return pd.DataFrame(records) # ✅ 一次性构造
# ============================ 坑 ③ 隐式拷贝 / 链式赋值 ============================
def p3_slow(df: pd.DataFrame, thresholds) –> pd.DataFrame:
out = df.copy()
for k, thr in enumerate(thresholds):
# ❌ 反复对整张大表做切片拷贝(真实代码里常写成 df[mask].copy() 再赋值)
sub = out[out["volume"] > thr].copy()
sub["bucket"] = k
_ = sub
return out
def p3_fast(df: pd.DataFrame, thresholds) –> pd.DataFrame:
out = df.copy()
out["bucket"] = np.digitize(out["volume"].to_numpy(), thresholds) # ✅ 一次向量化分箱
return out
# ============================ 坑 ④ apply 逐元素 (axis=1) ============================
def p4_slow(df: pd.DataFrame) –> pd.DataFrame:
out = df.copy()
out["adj"] = df.apply( # ❌ 每行一次 Python 调用
lambda r: r["close"] * (1.0 if r["volume"] > 1_000_000 else 0.5), axis=1)
return out
def p4_fast(df: pd.DataFrame) –> pd.DataFrame:
out = df.copy()
out["adj"] = np.where(df["volume"] > 1_000_000, # ✅ 向量化分支
df["close"], df["close"] * 0.5)
return out
# ============================ 坑 ⑤ 类型不降级(内存) ============================
def p5_downcast(df: pd.DataFrame) –> tuple[int, int, pd.DataFrame]:
mem_before = int(df.memory_usage(deep=True).sum())
d = df.copy()
for c in ["open", "high", "low", "close"]:
d[c] = d[c].astype("float32") # float64 -> float32 省一半
d["volume"] = d["volume"].astype("int32") # int64 -> int32
d["code"] = d["code"].astype("category") # 500 个字符串 -> 枚举
d["date"] = pd.to_datetime(d["date"]) # object 字符串 -> datetime64
mem_after = int(d.memory_usage(deep=True).sum())
return mem_before, mem_after, d
# ============================ 坑 ⑥ 循环里反复 merge ============================
def p6_slow(df: pd.DataFrame, codes) –> pd.DataFrame:
merged = df.copy()
for code in codes: # ❌ 每只股票 merge 一次,越合越宽
tmp = df[df["code"] == code][["code", "close"]].rename(columns={"close": f"c_{code}"})
merged = merged.merge(tmp, on="code", how="left")
return merged
def p6_fast(df: pd.DataFrame, codes) –> pd.DataFrame:
lookup = df.groupby("code")["close"].last().rename("last_close").reset_index() # ✅ 一次聚合
return df.merge(lookup, on="code", how="left")
# ============================ 坑 ⑦ read_csv 不设 dtype ============================
def p7_read(path: str, **kw) –> pd.DataFrame:
return pd.read_csv(path, **kw)
# ============================ 主流程 ============================
def main() –> None:
banner("S2-017 性能跑分 · 7 个坑 + 10万→1000万 线性放大")
print("环境: pandas", pd.__version__, "| numpy", np.__version__)
print("说明: 所有数据为 A股风格合成面板(固定种子,非真实行情)。计时取最优一次。\\n")
rows = [] # (坑, 数据规模, 慢写法/s, 快写法/s, 加速比)
# ———- 坑① iterrows ———-
N1 = 100_000
df1 = make_panel(N1)
t_slow, _ = timeit(p1_slow, df1, n_repeat=1)
t_fast, _ = timeit(p1_fast, df1, n_repeat=3)
ratio = t_slow / t_fast
rows.append(("① iterrows 逐行", f"{N1:,} 行", t_slow, t_fast, ratio))
banner(f"坑① iterrows 逐行循环(N={N1:,})")
print(f"慢 (iterrows): {t_slow:8.3f} s")
print(f"快 (向量化) : {t_fast*1000:8.2f} ms")
print(f"加速比 : {ratio:10.0f}x")
# ———- 坑② 循环 append ———-
N2 = 5_000
rng = np.random.default_rng(11)
records = [{"code": f"{int(rng.integers(0,500)):06d}",
"close": float(rng.uniform(5, 50))} for _ in range(N2)]
t_slow, _ = timeit(p2_slow, records, n_repeat=1)
t_fast, _ = timeit(p2_fast, records, n_repeat=3)
ratio = t_slow / t_fast
rows.append(("② 循环内 append", f"{N2:,} 条记录", t_slow, t_fast, ratio))
banner(f"坑② 循环里反复 concat/append(N={N2:,} 条)")
print(f"慢 (循环 concat): {t_slow:8.3f} s")
print(f"快 (一次构造) : {t_fast*1000:8.2f} ms")
print(f"加速比 : {ratio:10.0f}x")
# ———- 坑③ 隐式拷贝 / 链式赋值 ———-
N3 = 200_000
df3 = make_panel(N3)
thresholds = np.linspace(df3["volume"].min(), df3["volume"].max(), 200)
t_slow, _ = timeit(p3_slow, df3, thresholds, n_repeat=1)
t_fast, _ = timeit(p3_fast, df3, thresholds, n_repeat=3)
ratio = t_slow / t_fast
rows.append(("③ 隐式拷贝/分箱", f"{N3:,} 行 × 200 桶", t_slow, t_fast, ratio))
banner(f"坑③ 反复切片拷贝 / 链式赋值(N={N3:,}, 200 个阈值)")
print(f"慢 (反复整表拷贝): {t_slow:8.3f} s")
print(f"快 (digitize 一次): {t_fast*1000:8.2f} ms")
print(f"加速比 : {ratio:10.0f}x")
# 额外演示链式赋值「写不进去」的 bug
demo = df3.head(5).copy()
try:
demo[demo["volume"] > 1_000_000]["flag"] = 1 # 链式赋值,通常无效
except Exception as e: # pragma: no cover
print("链式赋值异常:", e)
print(f"链式赋值后 flag 列是否存在: {'flag' in demo.columns} <- 大概率 False(写入失败)")
# ———- 坑④ apply axis=1 ———-
N4 = 200_000
df4 = make_panel(N4)
t_slow, _ = timeit(p4_slow, df4, n_repeat=1)
t_fast, _ = timeit(p4_fast, df4, n_repeat=3)
ratio = t_slow / t_fast
rows.append(("④ apply 逐元素", f"{N4:,} 行", t_slow, t_fast, ratio))
banner(f"坑④ apply(axis=1) 逐行(N={N4:,})")
print(f"慢 (apply 行级): {t_slow:8.3f} s")
print(f"快 (np.where) : {t_fast*1000:8.2f} ms")
print(f"加速比 : {ratio:10.0f}x")
# ———- 坑⑤ 类型不降级 ———-
N5 = 1_000_000
df5 = make_panel(N5)
mem_before, mem_after, df5d = p5_downcast(df5)
mb_b, mb_a = mem_before / 1e6, mem_after / 1e6
ratio = mem_before / mem_after
rows.append(("⑤ 类型不降级(内存)", f"{N5:,} 行", mb_b, mb_a, ratio))
banner(f"坑⑤ 类型不对 → 内存爆(N={N5:,})")
print(f"默认 dtype 内存: {mb_b:8.1f} MB")
print(f"降级后 dtype 内存: {mb_a:8.1f} MB")
print(f"省下内存 : {(1 – mem_after/mem_before)*100:6.1f}% (约 {ratio:.1f}x 更省)")
print("降级方案: float64→float32 | int64→int32 | code→category | date→datetime64")
# ———- 坑⑥ 循环内 merge ———-
N6 = 100_000
df6 = make_panel(N6)
codes6 = list(pd.unique(df6["code"]))[:50] # 取 50 只股票演示(5000 只全A股会更夸张)
t_slow, _ = timeit(p6_slow, df6, codes6, n_repeat=1)
t_fast, _ = timeit(p6_fast, df6, codes6, n_repeat=3)
ratio = t_slow / t_fast
rows.append(("⑥ 循环内 merge", f"{N6:,} 行 × {len(codes6)} 只", t_slow, t_fast, ratio))
banner(f"坑⑥ 循环里反复 merge(N={N6:,}, {len(codes6)} 只股票)")
print(f"慢 (循环 merge): {t_slow:8.3f} s")
print(f"快 (groupby+一次merge): {t_fast*1000:8.2f} ms")
print(f"加速比 : {ratio:10.0f}x")
# ———- 坑⑦ read_csv 不设 dtype ———-
N7 = 1_000_000
df7 = make_panel(N7)
tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False, encoding="utf-8")
tmp_path = tmp.name
df7.to_csv(tmp_path, index=False)
tmp.close()
fsize = os.path.getsize(tmp_path) / 1e6
t_slow, df_slow = timeit(lambda: p7_read(tmp_path), n_repeat=1)
fast_dtype = {"code": "category", "open": "float32", "high": "float32",
"low": "float32", "close": "float32", "volume": "int32"}
t_fast, df_fast = timeit(
lambda: p7_read(tmp_path, dtype=fast_dtype, parse_dates=["date"]), n_repeat=3)
mem_slow = int(df_slow.memory_usage(deep=True).sum()) / 1e6
mem_fast = int(df_fast.memory_usage(deep=True).sum()) / 1e6
ratio = mem_slow / mem_fast
# 下游收益:同样的 groupby,胖表(object 键) vs 瘦表(category 键)
t_gfat, _ = timeit(lambda: df_slow.groupby("code", observed=True)["close"].mean(), n_repeat=1)
t_gthin, _ = timeit(lambda: df_fast.groupby("code", observed=True)["close"].mean(), n_repeat=3)
rows.append(("⑦ read_csv 裸读", f"{N7:,} 行 CSV {fsize:.0f}MB", t_slow, t_fast, ratio))
banner(f"坑⑦ read_csv 不设 dtype(CSV {fsize:.0f} MB, N={N7:,})")
print(f"慢 (裸读,全默认): {t_slow:8.3f} s 读后内存 {mem_slow:6.1f} MB")
print(f"快 (设 dtype+parse_dates): {t_fast*1000:7.2f} ms 读后内存 {mem_fast:6.1f} MB")
print(f"读入耗时: 相近甚至略慢(多了类型转换)|但内存省 {(1 – mem_fast/mem_slow)*100:5.1f}%")
print(f"下游 groupby 对比: 胖表 {t_gfat*1000:6.1f} ms vs 瘦表 {t_gthin*1000:6.1f} ms "
f"(category 键快 {t_gfat/t_gthin:.0f}x)")
try:
os.remove(tmp_path)
except OSError:
pass
# ============================ 10万 → 1000万 线性放大 ============================
banner("线性放大演示:同一段「慢代码」,行数 ×100 → 耗时 ×100")
print("用坑①的 iterrows 写法(最典型),在两种规模上计时:\\n")
scale_rows = []
for N in (100_000, 1_000_000):
df = make_panel(N)
t_slow, _ = timeit(p1_slow, df, n_repeat=1)
t_fast, _ = timeit(p1_fast, df, n_repeat=3)
print(f" N={N:>9,} 行 | 慢写法 {t_slow:7.2f}s | 快写法 {t_fast*1000:7.2f}ms | 慢/快 {t_slow/t_fast:8.0f}x")
scale_rows.append((N, t_slow, t_fast))
n100k, t100k, _ = scale_rows[0]
n1m, t1m, _ = scale_rows[1]
print(f"\\n 10万→100万:慢写法 {t1m/t100k:5.1f}x(近似线性 ×10)")
print(f" 推算 10万→1000万:慢写法 ≈ ×{t100k * 100 / t100k:.0f}(行数 ×100 = 耗时 ×100)")
print(f" → 1000万行时,这段 iterrows 大约要 {t1m*10:6.1f}s(约 {t1m*10/60:.1f} 分钟)一言不合跑一下午;")
print(f" 而向量化写法在 1000万行依旧是亚秒级({(t_fast if False else scale_rows[1][2])*10*1000:6.1f} ms 量级)。")
# ============================ 汇总表 ============================
banner("7 个坑 · 收益汇总(同一环境实测)")
print(f"{'坑':<22}{'规模':<20}{'慢':>12}{'快':>12}{'收益':>12}")
print("-" * 80)
for name, scale, ts, tf, ratio in rows:
if "内存" in name: # ⑤:内存视角
print(f"{name:<22}{scale:<20}{ts:>9.1f}MB{tf:>9.1f}MB{'省%.0f%%' % ((1 – tf / ts) * 100):>12}")
elif "read_csv" in name: # ⑦:读入耗时相近,内存 + 下游双赚
slow_s = f"{ts:8.2f}s"
fast_s = f"{tf:8.2f}s"
print(f"{name:<22}{scale:<20}{slow_s:>12}{fast_s:>12}{'内存-%.0f%%' % ((1 – 1/ratio) * 100):>12}")
else: # ①②③④⑥:耗时视角
slow_s = f"{ts:8.2f}s" if ts >= 1 else f"{ts*1000:8.1f}ms"
fast_s = f"{tf:8.2f}s" if tf >= 1 else f"{tf*1000:8.1f}ms"
print(f"{name:<22}{scale:<20}{slow_s:>12}{fast_s:>12}{ratio:>10.0f}x")
print("\\n结论:把 7 个坑里的「慢写法」换成「快写法」,常见加速 150x~7000x;")
print("当数据从 10万 涨到 1000万行,慢写法的耗时按行数线性放大(×100),快写法依旧亚秒级。")
print("\\n" + "=" * 68)
print(" S2-017 跑分完毕")
print("=" * 68)
if __name__ == "__main__":
main()
脚本内含:7 个坑的 slow/fast 实现、内存对比、下游 groupby 对比,以及「10万→1000万」线性放大演示。所有数据均为 A 股风格合成面板(固定随机种子,非真实行情),仅用于演示性能放大效应,不构成任何投资建议。
免责声明:本文所有内容仅用于量化投资技术教学与知识分享,文中涉及的代码示例、跑分数据与实证结果均不构成任何投资建议或个股推荐。投资有风险,入市需谨慎;实际交易前请务必用自己的真实数据充分回测,并充分了解相关风险。


