第08讲 数据加载与存储

📎 配套代码第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_tableread_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_codetrade_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——需要增量更新、随机查询、多表关联的场景。日更的行情库、交易记录、任务状态。

还有个 HDF5to_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 只股票

传了 chunksizeread_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 后面出现的列都该考虑。

✍️ 作业

  1. 找一份本地的 Parquet 数据(没有就用第 3 节的方法自己造一份),用四种方式读:全读、只限列、只限行、两个都限。记录每种的耗时、行数和 memory_usage(deep=True).sum(),确认量级差异。
  2. 把一份含股票代码的表存成 CSV,分别用默认参数和 dtype={"code": str} 读回来,打印两次的值和 dtype。再存一份 Parquet 读回来对比,说明为什么 Parquet 不需要指定这个参数。
  3. sqlite3 建一个本地库,写入至少十万行行情数据。先不建索引,用 read_sql 查单只股票并计时;再 CREATE INDEX 后重查计时。记录两次耗时。
  4. 拿一份读进来的行情表,打印 df.memory_usage(deep=True).sort_values(ascending=False),找出最吃内存的列。把文本列转 category、日期列转 datetime64,再打印一次,记录节省比例。
  5. 思考题:第 3 节说 Parquet 的谓词下推靠”每个 row group 记着每列的最大最小值”。那么,如果一份数据在写入前trade_date 排好序,和完全随机打乱filters=[("trade_date", ">=", "20240101")] 的效果会有区别吗?为什么?(提示:想想乱序时每个 row group 的 trade_date 范围会是什么样。)

🔮 下讲预告:第 09 讲——缺失数据与排序。这一讲你已经见过一次缺失的代价:整数列一出现 NaN 就被抬成 float64。下一讲讲清缺失的两个静默伤害,以及删、填、留三条处理路径——包括 bfill 在回测里为什么是未来函数。后半讲排序,看起来是两件事,但它们在同一处交汇:排序就是一连串比较,而 NaN 参与比较一律是 False。第 01 讲开场那个不报错的 bug,也会在那一讲收口。


← 上一讲  ·  返回课程  ·  下一讲 →