← 返回模块
3.2.2.2beta 可读 · 未来付费内容校验中内容版本 2026-05-27

数据读写与清洗

3.2.2 · Pandas · 编程

周一早晨九点,你在一家私募的研究台上收到上游数据团队推过来的 510300_2024.csv——沪深300 ETF 在 2024 年 1 月的日线行情。你打算直接 df = pd.read_csv(path) 然后开始写信号,结果跑出来的 DataFrame 漏洞密布:close 列的 dtype 是 object 而不是 float64(因为有三行写着 "N/A"),date 列是字符串而不是 datetime64[ns](其中一行还带了尾随空格),2024-01-15 那一天被 vendor 双发成了两行,2024-01-05 整行干脆不在文件里。这不是个例——国内 A 股 vendor(Wind、同花顺、优矿)给你的 CSV,基本每一份都长这样。这一节要解决的就是:用一套确定性、可复用的几行代码,把这种脏文件变成模块后续课程默认就在使用的「类型正确、索引正确、无重复、缺失值被显式处理」的 DataFrame。

read_csv 的四个承重参数

实务里 read_csv 真正吃重的参数只有四个,缺一个都会埋雷:

df = pd.read_csv(path, parse_dates=['date'], index_col='date', dtype={'volume': 'int64'}, na_values=['N/A', '', '-'])

逐个看:

  • parse_dates=['date']:在读盘的同一步就把日期字符串解析成 datetime64[ns]。比「读完再补一次 pd.to_datetime」快一截,更重要的是避开「忘了转、df.loc['2024-01-15'] 拿到错的行」这种沉默 bug。
  • index_col='date':把 date 一步提升为 DataFrame 的索引。时序数据里,索引才是行的真正身份——df.loc['2024-01-15']df[df['date'] == '2024-01-15'] 短得多、也快得多。
  • dtype={'volume': 'int64'}:显式把 volume 锁成 int64,价格类列留给 Pandas 推断成 float64。不指定的话,只要列里出现一个 "N/A",整列就会被推断成 object——这是 Pandas 里最隐蔽的 bug 源头,因为 df['volume'].sum() 会突然变成字符串拼接而不是数字求和。
  • na_values=['N/A', '', '-']:把 vendor 的「自定义缺失值」并入标准的 NaN'N/A' 是国内 vendor 的常客,'' 是 Excel 导出来的空格,'-' 是某些券商的占位符。

顺手记三个加分参数:usecols= 只读你需要的列(百列以上的宽表能省一个数量级的 IO 时间);nrows=1000 在开发期先取前 1000 行看一眼数据形态;chunksize= 是真正放不进内存时的入口,详细处理留到 3.3.1 并发与性能那一课。

Parquet 比 CSV 强在哪

CSV 适合给人看、给非技术同事邮件附件;中间状态(已清洗好、准备喂给后续 pipeline 的 DataFrame)应当写成 Parquet。

df.to_parquet('cleaned.parquet')
df2 = pd.read_parquet('cleaned.parquet')

引擎默认走 pyarrow(清华、阿里 pip 镜像都收录了 pyarrow,国内安装无障碍)。Parquet 比 CSV 强的两点都是承重柱:

  • ​dtype 在往返中保真​​:写出去是 datetime64[ns],读回来还是 datetime64[ns];CSV 走一圈很可能被重新推断成 object,于是你又要再做一次 pd.to_datetime
  • ​列式存储(columnar storage)​​:pd.read_parquet('cleaned.parquet', columns=['close', 'volume']) 只从磁盘上读这两列。宽表上比「CSV 全读再切」快 10–100 倍。

一句话:.csv.gz 用来归档与跨团队分发,Parquet 用来跑流水线。注意国内 vendor 给的原始数据通常仍是 CSV 或自研二进制——Parquet 是你清洗后的中间格式,不是 vendor 入口格式。

缺失值:先数、再选、再数一次

Pandas 的 NaNfloat64 的一个具体值(即 np.nan),不是哨兵字符串。它会沿算术运算传播——1.0 + np.nan 等于 nan——并且必须用 df.isna() 来检测,绝不能写 == np.nan,因为 np.nan == np.nan 恒为 Falsepd.NA 是面向可空整数与可空字符串 dtype 的新一代缺失值表示,本节不展开。

任何清洗流水线的第一行都是体检:

df.isna().sum()

它返回每一列的 NaN 计数,是你做后续清洗决策的依据。然后按列选策略,绝不全表一刀切:

df['volume'] = df['volume'].fillna(0)
df['close'] = df['close'].ffill(limit=1)
df = df.dropna(subset=['close'])
  • fillna(0) 只对「加性数量」合法——volume 在无成交日填 0 是对真实世界的诚实表达;价格或收益率填 0 是凭空制造异常值,绝不能这么做。
  • ffill(limit=1) 是「上一根有效价格」的标准补法,但 ​必须​ 显式给 limit=1,否则一个春节长假能把节前一天的价格连续向前填七个交易日,下游算出来的日收益率就成了一串零。
  • dropna(subset=['close']) 是最后关卡:close 是你无法合理编造的关键字段,缺了就把整行丢掉,不要尝试任何插值。

关于 interpolate(method='time'):在周五 close 与下周二 close 之间用时间插值得到一个周一价格,本质是凭空编造——回测里如果用了这个值,你拿到的就是带 future information 的「答案」。ffill 把「截至当下已知」的信息向前传播,这是合法的,前提是回测器在那个时间点本来也只看得到那个值;interpolate(method="time") 不是合法的回测输入,因为它使用了 future information——这就是 look-ahead bias 的教科书形态。

dtype 体检与修复

清洗的第二行总是:

df.dtypes

应该是数值的列却显示为 object,意味着列里至少有一格不是合法数字。修复一行:

df['close'] = pd.to_numeric(df['close'], errors='coerce')

errors='coerce' 把非数字格子转成 NaN,接着继续用上一节的策略处理。日期列同理:df['date'] = pd.to_datetime(df['date']),加 format='%Y-%m-%d' 还能再快一截。低基数的字符串列(如 sector)适合 df['sector'] = df['sector'].astype('category'),长表上能省下相当可观的内存——一行的事,不展开。

重复行与重复索引

重复要分两种看,绝不能混。​​第一种是重复的行​​——所有列都相同,通常是 vendor 重发:

dup_rows = df.duplicated().sum()
df = df.drop_duplicates()

​第二种是重复的索引标签​​——同一日期出现了两行(取值可能不同但索引一样),常因 vendor 在盘后又发了一份订正版却没回收原版:

dup_index = df.index.duplicated().sum()

索引重复是真正的隐患:df.loc['2024-01-15'] 在索引唯一时返回 Series,在索引重复时悄悄返回 DataFrame,下游任何 .shift() / resample() / 与另一张表的 join 都会因此挂掉,但不会报错。检测到要走业务核对再决定保留哪一行,不要默认 keep='first' 草草过场。

一条完整的清洗流水线

把上面所有手势串起来。假设 path 指向开头那份 510300_2024.csv(25 行,两行 2024-01-15 重复,close 里三格 'N/A',一行日期 '2024-01-03 ' 带尾随空格,缺一行 2024-01-05):

df = pd.read_csv(path, parse_dates=['date'], index_col='date', dtype={'volume': 'int64'}, na_values=['N/A', '', '-'])
df.isna().sum()                                              # 1. 体检:NaN 分布在哪几列
df['close'] = pd.to_numeric(df['close'], errors='coerce')    # 2. 把残留的 'N/A' 真正变成 NaN
df = df.drop_duplicates()                                    # 3. 去掉 2024-01-15 重发的那一行
df['volume'] = df['volume'].fillna(0)                        # 4. 假日 volume 填 0
df = df.dropna(subset=['close'])                             # 5. close 缺失的行整行丢
df.dtypes                                                    # 6. 复检:应全为 float64 / int64

落盘交给后续课程使用:df.to_parquet('510300_2024.cleaned.parquet')。生产 pipeline 上建议再加一道 pandera / great_expectations 的 schema 校验作为合约,把「dtype 与允许的缺失率」沉淀成可断言的规则——细节在 3.3.2 模式与工具那一节展开。

下一课接力

到这里你已经能把一份脏 CSV 转成一份后续课程可以放心使用的、单表 DataFrame。下一课要回答的是另一个问题:​​多张​ 表怎么对齐?把这份 510300 的日线和申万一级行业分类表 join 起来;把长表 (date, ticker, return) 透视成 (T, N) 的宽矩阵;再按行业分组算每个行业的日均波动——merge / pivot_table / groupby 这三个真正区分 Pandas 与 NumPy 的动词,是下一节的全部内容。

练习

Exercise

给定 CSV 文件 path,列为 date,close,volume,其中 close 列可能含有字符串 "N/A""-" 表示缺失值,文件中也可能存在重复行。请写一个 Pandas 函数 load_clean(path) -> pd.DataFrame,返回一个由 datetime64[ns] 类型的 date 列作为索引、含两列数值列(close: float64volume: int64)的 DataFrame,且无重复行、no NaN in close, volume NaNs filled with 0。

提示
pd.read_csv 时同时给 parse_dates=['date']index_col='date'na_values=['N/A', '-'] 三个参数——一行把日期解析、索引提升与 vendor 缺失值识别三件事一并处理。
提示
清洗顺序很关键:先 drop_duplicates() 去掉整行重复;再对 closeto_numeric(errors='coerce') 把残留字符串变成 NaN;接着对 volumefillna(0);最后 dropna(subset=['close']) 收尾。