📎 配套代码:
第08讲_数据加载.py
📊 配套数据:data/quote/data/industry/—— 交易数据、行业数据(沪深300 × 2024 年以来,仓库自带,开箱即跑)
🎬 开场:同一个文件,同一个函数,差 700 倍内存
仓库自带一份 A 股日线样本,data/quote/pro_bar_hs300.parquet,4MB,17 万行 × 9 列(沪深 300 成分 × 2024 年以来)。
把它读进来:
df = pd.read_parquet("data/quote/pro_bar_hs300.parquet")
0.01s 171,347 行 × 9 列 内存 24.5MB
读得很快。但它在内存里占了 24.5MB——磁盘上只有 4MB,膨胀了六倍。
而你这次分析其实只想看茅台一只股票的收盘价:
df = pd.read_parquet("data/quote/pro_bar_hs300.parquet", columns=["trade_date", "close"], filters=[("ts_code", "==", "600519.SH")])
0.00s 574 行 × 2 列 内存 0.035MB
同一个文件、同一个函数,只是多传了两个参数:时间省 6 倍,内存省 697 倍。
两种写法都能得到正确答案。区别在于第一种把 300 只股票两年的开高低收全部搬进内存,然后你从里面挑了两列一只股票;第二种让文件格式本身把不需要的部分挡在磁盘外。
(样本只有 4MB,绝对时间都在毫秒级。换成几百 GB 的真实数据库,同样的倍数意味着几分钟和几小时的差别。倍数才是这一讲的重点。)
读数据这件事,真正的技能不是”怎么读进来”,而是”怎么只读你要的那部分”。 而能不能做到这一点,取决于数据存成了什么格式。
🎯 这一讲结束时,你能
- 用
read_csv的核心参数处理真实世界的脏文本:中文编码、脏行、缺失哨兵、股票代码的前导零 - 说清 CSV 的根本短板(不存类型),以及为什么本地量化数据基本都用 Parquet
- 用
columns=和filters=让 Parquet 只读需要的部分,并解释为什么它做得到而 CSV 做不到 - 用 SQLite 存那些需要反复增量更新、随机查询的数据,并知道该给哪一列建索引
- 面对超出内存的文件,用
chunksize写出增量聚合,而不是分块读完再拼回去
一、🧰 read_csv:文本文件的入口
CSV 是最常见的交接格式——券商导出、同事发来的、网站下载的,多半是它。
pd.read_csv 参数很多,但真正常用的就几组:
| 你要解决的问题 | 参数 |
|---|---|
| 第一行是表头还是数据 |
header / names
|
| 用什么分隔 |
sep(默认逗号,\t 制表符,r"\s+" 不定量空白) |
| 哪列当行索引 | index_col |
| 某列强制成什么类型 | dtype |
| 哪列是日期 |
parse_dates + date_format
|
| 什么算缺失 | na_values |
| 跳过脏行 |
skiprows / comment="#"
|
| 只要一部分 |
usecols(只读指定列)/ nrows(只读前 n 行) |
| 中文乱码 |
encoding("gbk" / "utf-8-sig") |
read_table 和 read_csv 是同一个东西,只差默认分隔符(前者 \t,后者 ,)。记一个就够,特殊分隔用 sep= 覆盖。
中文数据的编码
国内券商、交易所导出的文件常常是 GBK 编码,直接读会乱码或直接报错:
pd.read_csv("持仓.csv", encoding="gbk") # 券商导出常见 pd.read_csv("data.csv", encoding="utf-8-sig") # Excel 另存的 UTF-8 带 BOM
读进来是乱码,几乎总是编码没对上,和 pandas 无关。
🎮 随堂快练
QUESTION: 一个 300 列的宽表 CSV,你只需要其中 3 列。用哪个参数?它省的是什么?
TIP: 👉 答案
usecols=["ts_code", "trade_date", "close"]。省两样东西:解析成本(不需要的列根本不解析)和内存(不进 DataFrame)。这和后面 Parquet 的columns=是同一个思路,区别在于 CSV 仍然要把整个文件的字节扫一遍才能找到列的边界,Parquet 可以直接跳过。
二、🐛 CSV 的根本短板:它不存类型
CSV 是纯文本,一个数字和一段文字在文件里长得一样,都是字符。类型信息不在文件里,只能靠 pandas 猜。
猜错的后果在 A 股数据上格外明显:
tiny = pd.DataFrame({"code": ["000001", "000858", "600519"], "close": [11.8, 152.3, 1680.0]}) tiny.to_csv("code.csv", index=False) pd.read_csv("code.csv")["code"]
真跑:
原始 : ['000001', '000858', '600519'] 默认读 : [1, 858, 600519] dtype=int64 ← 前导零没了 dtype=str : ['000001', '000858', '600519'] dtype=object
平安银行的代码 000001 变成了整数 1。 后面你拿这个去和别的表按代码合并,一条也对不上——而且不报错。
同样的事情发生在日期上。上面那份 tushare 数据里 trade_date 本来是字符串 "20240102",存成 CSV 再读回来:
CSV 读完 trade_date 的类型: int64 (原始 object)
变成了两千多万这个量级的整数。它还能用,但已经不是日期了。
WARNING: ⚠️ 凡是”看起来像数字但其实是编号”的列,都要显式指定
股票代码、订单号、身份证、邮编、银行卡号——共同特点是前导零有意义、且永远不参与算术运算。一律dtype={"code": str}。
这不是 pandas 的缺陷,是 CSV 格式本身的信息缺失:文件里没写这列是什么类型,pandas 只能按”看起来像什么”来猜,而它猜得相当积极。
三、🧰 Parquet:本地量化数据的主流格式
Parquet 把类型和数据一起存,所以读回来不用猜,也没有前导零问题:
parquet : ['000001', '000858', '600519'] ← 类型存在文件里
把这份样本行情(17 万行)分别存成两种格式,真跑对比:
| CSV | Parquet | |
|---|---|---|
| 文件大小 | 11.4 MB | 4.1 MB |
| 写入耗时 | 0.33 s | 0.03 s |
| 读取耗时 | 0.04 s | 0.004 s |
| 类型 | 丢失,要靠猜 | 原样保留 |
体积小 2.8 倍,读快约 9 倍,还不丢类型。(耗时随机器和磁盘不同会有出入,量级是稳定的。)这就是为什么本地量化数据基本都存 Parquet——tushare、akshare 的下载脚本,qlib 的数据层,默认落地格式都是它或它的近亲。
只读需要的列
pd.read_parquet(PQ, columns=["ts_code", "trade_date", "close"])
只读需要的行
filters 接受一个条件列表,格式是 (列名, 运算符, 值):
pd.read_parquet(PQ, filters=[("ts_code", "==", "600519.SH")]) pd.read_parquet(PQ, filters=[("trade_date", ">=", "20240101")])
多个条件放在同一个列表里是与关系;要表达或,用嵌套列表 [[条件A], [条件B]]。
四种读法在这份 17 万行的文件上真跑:
| 写法 | 耗时 | 行数 | 内存 |
|---|---|---|---|
| 全读 | 0.010 s | 171,347 | 24.5 MB |
columns= 三列 |
0.005 s | 171,347 | 20.4 MB |
filters= 单只股票 |
0.002 s | 574 | 0.10 MB |
| 两个都用 | 0.002 s | 574 | 0.035 MB |
filters 的效果比 columns 更猛,因为它把 17 万行砍到了 574 行。
IMPORTANT: 🔑
filters不等于读完再筛
pd.read_parquet(PQ)[lambda d: d.ts_code=="600519.SH"]和上面第三行结果完全一样,代价差 700 倍内存。前者先把整表搬进内存再扔掉 99.7%;后者压根没读那些数据。
判断标准很简单:筛选条件如果在读之前就知道,就写进filters,不要读完再筛。
🎮 随堂快练
QUESTION: 你要算全样本的日均换手率。
daily_basic_hs300.parquet有 17 万行 × 8 列。怎么读?
TIP: 👉 答案pd.read_parquet("data/quote/daily_basic_hs300.parquet", columns=["trade_date", "turnover_rate"], filters=[("trade_date", ">=", "20250101")])8 列里只要 2 列,时间上只要一段。两个参数都用上——你需要的数据可能只占原文件的百分之几。
四、🔬 为什么 Parquet 能”只读两列”
这一节解释的是上面那个 columns= 为什么有效。它值得讲,是因为它直接决定你该怎么写代码。
关键在于数据在文件里的排列顺序。
CSV 是按行存的。文件里长这样:
600519.SH,20240102,1680.0,1695.0,1670.0,1688.0,... 600519.SH,20240103,1688.0,1702.0,1681.0,1699.0,...
一行的所有字段挨在一起。你想只要 close 一列,也得把每一行完整扫过去,数逗号数到第 6 个,才知道 close 在哪。不读完整个文件,就找不齐一整列。
Parquet 是按列存的。同一列的值挨在一起:
[ts_code 全部值......][trade_date 全部值......][close 全部值......]
再加上文件头部记着”每一列从第几个字节开始”。于是”只读 close 列”就是一次定位加一次连续读取,其它列的字节根本没经过内存。
按列存还带来另外两个好处:
-
压缩率高。同一列的值类型相同、取值相近(比如
ts_code就那 300 个值反复出现),压缩算法能吃掉大量重复。这就是 11.4MB 变 4.1MB 的原因。 -
能跳过整块数据。Parquet 把行分成若干组(row group,这份样本是 29 组),每组每列都记着最大值最小值。
filters=[("trade_date",">=","20240101")]执行时,先看每组的trade_date范围,整组都在 2024 年之前的直接跳过,连解压都不用。这叫谓词下推。
NOTE: 💡 这个区别决定了什么时候不该用 Parquet
列式存储擅长”少数几列、大量行”的分析型读取,不擅长”取出某一整行的全部字段”——那需要从每一列各取一个值,反而分散。
所以:做研究分析用 Parquet,需要按记录逐条读写更新的场景用数据库。
五、🧰 SQLite:一个文件就是一个数据库
Parquet 有个明显的短板:它是一次性写死的。想往里追加一天的新数据,得把整个文件读出来、拼上、再整个写回去。
SQLite 补的正是这块。它不需要装服务、不需要配置,一个 .db 文件就是一个完整的关系数据库,Python 标准库自带驱动。
import sqlite3 con = sqlite3.connect("quotes.db") sub.to_sql("daily", con, index=False, if_exists="replace") # 写入 pd.read_sql("SELECT * FROM daily WHERE ts_code='600519.SH'", con) # 读出
if_exists 三个取值:"fail"(已存在就报错)、"replace"(删表重建)、"append"(追加)。增量更新用 append。
索引决定了查询快慢
把这 17 万行写进 SQLite(0.14s,文件 15MB),然后查一只股票,真跑:
无索引查单只股票 0.005s 574 行 建索引后再查 0.000s 574 行 → 快约 14 倍
差别只有一行代码:
con.execute("CREATE INDEX ix_code ON daily(ts_code)")
没有索引,数据库只能把 17 万行整个扫一遍,逐行看 ts_code 是不是茅台。建了索引,它直接定位到那 574 行。
样本小,倍数只有 14;数据量上到几千万行时,这个差距会是几个数量级。
IMPORTANT: 🔑 该给哪列建索引
你写在WHERE后面的那些列。 量化场景里通常就是ts_code和trade_date。两列都常用就建联合索引ON daily(ts_code, trade_date)。
代价是写入变慢、文件变大——索引本身要占空间。所以不是列越多越好,按实际查询模式来。
让数据库干活
read_sql 里可以写任何 SQL。有些活交给数据库比读进 pandas 再算更划算:
pd.read_sql(""" SELECT ts_code, COUNT(*) AS n, AVG(close) AS avg_c FROM daily GROUP BY ts_code """, con)
ts_code n avg_c 000001.SZ 574 11.131202 000002.SZ 574 6.949146 000004.SZ 558 11.583763
聚合在数据库里完成,回到 Python 的只有 300 行结果,而不是 17 万行原始数据。
🎮 随堂快练
QUESTION: 你每天收盘后要把当天的行情追加进本地库,同时研究时经常按股票代码查历史。Parquet 还是 SQLite?
TIP: 👉 答案
SQLite。理由是”每天追加”——Parquet 追加要整个重写,一天一次、文件几百 MB 就很别扭了。而 SQLite 的to_sql(..., if_exists="append")只写新增的那部分,再给ts_code建个索引,按代码查也很快。
常见的做法是两者都用:SQLite 做落地和增量更新,定期导出一份 Parquet 做批量研究。
六、🤔 三种格式怎么选
| CSV | Parquet | SQLite | |
|---|---|---|---|
| 存类型 | ❌ 要靠猜 | ✅ | ✅ |
| 体积 | 大(本例 278MB) | 小(80MB) | 中(328MB) |
| 读整表 | 慢(1.03s) | 快(0.11s) | 中 |
| 只读几列 | 要扫全文件 | 直接跳过 |
SELECT 指定 |
| 只读几行 | 要扫全文件 | 谓词下推 | 索引,最快 |
| 追加数据 | 可以 | ❌ 要整个重写 | ✅ |
| 多表关联 | ❌ | ❌ | ✅ SQL JOIN
|
| 人能直接看 | ✅ | ❌ 二进制 | ❌ |
| 通用性 | ✅ 到处都认 | 需要 pyarrow | 需要 sqlite |
按用途归纳:
- CSV——和外部交接(发给别人、从网站下载)。自己长期存数据不要用它。
- Parquet——研究用的数据集,一次落地、反复批量读取。本地行情、因子库、回测输入。
- SQLite——需要增量更新、随机查询、多表关联的场景。日更的行情库、交易记录、任务状态。
还有个 HDF5(to_hdf/read_hdf),定位和 Parquet 接近,也支持条件查询。在量化圈子里 Parquet 的生态更活跃,新项目一般选 Parquet。
七、🧰 读进来之后,内存还能再降
回头看开场那个数字:磁盘 4MB,读进内存 24.5MB,膨胀了六倍。就算只读三列也还有 20MB。
拆开看是哪几列在吃内存:
ts_code (object) 10 MB trade_date(object) 10 MB close (float64) 1.4 MB
两列文本占了 20MB,而真正的数值只有 1.4MB。
原因是 pandas 默认把字符串存成 object——一个装 Python 字符串对象的指针数组,每个字符串都是一个独立的 Python 对象,开销极大。而 ts_code 翻来覆去只有 300 个不同的值,trade_date 只有 574 个不同的日期。
改两处类型:
df["ts_code"] = df["ts_code"].astype("category") df["trade_date"] = pd.to_datetime(df["trade_date"], format="%Y%m%d")
真跑:
ts_code (category) 0.2 MB ← 10MB → 0.2MB trade_date(datetime) 1.4 MB ← 10MB → 1.4MB 合计 20MB → 2MB 省 88%
category 把重复的字符串换成一张码表加一列整数编码(第 11 讲细讲),datetime64 把日期字符串换成 int64 纳秒时间戳(第 18 讲细讲)。两处改动省掉 88% 的内存。
TIP: 🚀 可以在读的时候就指定
pd.read_parquet(PQ, columns=[...]).astype({"ts_code": "category"})或者 CSV:
pd.read_csv(f, dtype={"ts_code": "category"}, parse_dates=["trade_date"])。
内存紧张时,先看哪几列是 object——df.memory_usage(deep=True).sort_values()一看就知道钱花在哪了。
八、🧰 超出内存的文件:chunksize
前面所有办法的前提是数据能装进内存。装不下的时候,chunksize 让你分块流式处理:
tot = None for chunk in pd.read_csv("sub.csv", chunksize=40_000, usecols=["ts_code", "amount"]): g = chunk.groupby("ts_code")["amount"].sum() tot = g if tot is None else tot.add(g, fill_value=0)
真跑(17 万行 CSV):
分块读 CSV 累加成交额 0.04s 5 块 300 只股票
传了 chunksize 的 read_csv 不返回 DataFrame,返回一个迭代器。每 for 一次才去磁盘读 4 万行、吐一个小 DataFrame,处理完就被回收。所以内存占用约等于一块的大小,和文件总大小无关。
关键在于增量聚合——每块算出部分结果,再把部分结果合并起来。求和、计数、最大最小值都可以这样做。
WARNING: ⚠️ 这样写等于白分块
df = pd.concat([chunk for chunk in chunker]) # ❌把所有块拼回一个完整的表,内存该爆还是爆。分块的意义是永远不组装整表。
也不是所有统计量都能增量合并。求和、计数可以;中位数、分位数不行——它们需要看到全部数据的分布,没法用几个部分结果拼出来。真需要的话得用近似算法,或者先把数据降到能装下的规模。
🏋️ 训练营
QUESTION: 🟢 训练 1:一份券商导出的持仓 CSV,GBK 编码,第一列是股票代码(带前导零),第三列是日期(形如
2024/01/02)。写出read_csv调用。
TIP: 👉 参考pd.read_csv("持仓.csv", encoding="gbk", dtype={"证券代码": str}, parse_dates=["日期"], date_format="%Y/%m/%d")三个参数各挡一个坑:
encoding挡乱码,dtype=str挡前导零被吃,parse_dates让日期变成能做时间运算的类型而不是一串文本。date_format显式给出格式比让 pandas 猜要快得多。
QUESTION: 🟡 训练 2:下面两段代码结果完全一样。指出代价差在哪,差多少。
# A df = pd.read_parquet(PQ) mao = df[df["ts_code"] == "600519.SH"]"trade_date", "close" # B mao = pd.read_parquet(PQ, columns=["trade_date", "close"], filters=[("ts_code", "==", "600519.SH")])TIP: 👉 参考
A 先把 17 万行 × 9 列全部读进内存(24.5MB),再从中挑出 574 行 2 列,其余 99.7% 读进来就是为了扔掉。
B 让 Parquet 在磁盘层面就把不要的行和列挡住(0.05s,0.4MB)。
差约 20 倍时间、约 7000 倍内存。在这份文件上 A 还能跑完,换成更大的数据集 A 就直接 OOM 了,而 B 毫无压力——因为 B 的内存开销只取决于结果大小,不取决于文件大小。
QUESTION: 🔴 训练 3:你要建一个本地行情库,需求是:① 每天收盘后追加当天数据 ② 研究时经常”取某几只股票的全部历史” ③ 偶尔要”取某一天的全市场截面”。设计存储方案。
TIP: 👉 参考
单一格式都不完美,实际做法是分层:SQLite(落地层)── 每日 to_sql(..., if_exists="append") 追加 │ 建联合索引 ON daily(ts_code, trade_date) │ └─ 定期导出 ─→ Parquet(研究层)── 批量读取,columns= / filters=理由:需求 ① 是追加,Parquet 做不了,必须 SQLite。需求 ②③ 是批量分析读取,Parquet 快一个数量级。
两个需求的访问模式根本不同,用两种格式各自承担,比逼着一种格式两头都干要合理。导出可以按年分文件(pro_bar_2024.parquet),这样连filters都省了——用文件名本身做分区是最朴素也最有效的谓词下推。
🐛 常见坑
- ⚠️ 股票代码前导零被吃掉:
000001读成1,后续按代码合并一条也对不上,且不报错。dtype={"code": str}。 - ⚠️ 中文乱码:券商导出多为 GBK,
encoding="gbk";Excel 另存的 UTF-8 带 BOM,用encoding="utf-8-sig"。 - ⚠️ 传了
names却没跳表头:文件有真表头时,传names会把表头当数据读进来。要配header=0。 - ⚠️ 读完再筛而不是
filters:结果一样,内存差几个数量级。筛选条件读之前就知道的,写进filters。 - ⚠️
chunksize读完又concat拼回去:等于没分块,内存照爆。 - ⚠️ 中位数不能增量合并:分块处理只对可加的统计量有效。
- ⚠️ object 列吃内存:文本列默认存成 object,开销是实际数据的几倍到几十倍。取值重复度高的列转
category。 - ⚠️ Parquet 不能追加:想加一行要整个文件重写。需要增量更新就用数据库,或按时间拆成多个文件。
- ⚠️ SQLite 忘了建索引:按代码查时全表扫描,数据量一大就是数量级的差距。
WHERE后面出现的列都该考虑。
✍️ 作业
- 找一份本地的 Parquet 数据(没有就用第 3 节的方法自己造一份),用四种方式读:全读、只限列、只限行、两个都限。记录每种的耗时、行数和
memory_usage(deep=True).sum(),确认量级差异。 - 把一份含股票代码的表存成 CSV,分别用默认参数和
dtype={"code": str}读回来,打印两次的值和 dtype。再存一份 Parquet 读回来对比,说明为什么 Parquet 不需要指定这个参数。 - 用
sqlite3建一个本地库,写入至少十万行行情数据。先不建索引,用read_sql查单只股票并计时;再CREATE INDEX后重查计时。记录两次耗时。 - 拿一份读进来的行情表,打印
df.memory_usage(deep=True).sort_values(ascending=False),找出最吃内存的列。把文本列转category、日期列转datetime64,再打印一次,记录节省比例。 - 思考题:第 3 节说 Parquet 的谓词下推靠”每个 row group 记着每列的最大最小值”。那么,如果一份数据在写入前按
trade_date排好序,和完全随机打乱,filters=[("trade_date", ">=", "20240101")]的效果会有区别吗?为什么?(提示:想想乱序时每个 row group 的trade_date范围会是什么样。)
🔮 下讲预告:第 09 讲——缺失数据与排序。这一讲你已经见过一次缺失的代价:整数列一出现
NaN就被抬成float64。下一讲讲清缺失的两个静默伤害,以及删、填、留三条处理路径——包括bfill在回测里为什么是未来函数。后半讲排序,看起来是两件事,但它们在同一处交汇:排序就是一连串比较,而NaN参与比较一律是False。第 01 讲开场那个不报错的 bug,也会在那一讲收口。