数据加载与预处理:用 cuDF 搞定 CSV 和 Parquet

好,咱们进入实战环节的第一步——把数据弄进来,再整理的干干净净。

说实话,我在早期教 GPU 加速课程时,发现不少同学在 cuDF 的 IO 上就卡住了。不是读不进来,就是读进来之后类型全乱了。其实说白了,cuDF 的 API 跟 Pandas 几乎一模一样,但底下走的完全是另一套路。

这一节,我带你亲手把 CSV 和 Parquet 喂给 GPU,顺便处理那些烦人的缺失值和数据类型问题。

4.1 cuDF 读取 CSV 文件

我们先从最常见的 CSV 开始。cuDF 的 read_csv 和 Pandas 的接口长得非常像,但参数上有些关键差异。

import cudf
import pandas as pd

# 读取 CSV
gdf = cudf.read_csv('user_behavior.csv',
                    dtype={
                        'user_id': 'int64',
                        'age': 'float32',
                        'income': 'float64'
                    },
                    na_values=['NA', 'NULL', ''],
                    usecols=['user_id', 'age', 'income', 'city'])

print(gdf.dtypes)

这里我用了 na_values 参数。为什么?因为之前我在一个电商数据集里,缺失值被写成 'NA' 和 'NULL' 混着来,cuDF 默认只认空字符串。那次排查了我半小时...

还有一个坑:不要用 CSV 的 header=None 然后自己拼列名。cuDF 的分区读取机制在 header 处理上有时会打架,我建议直接让 CSV 自带表头,或者用 names 参数指定并设置 header=0。

严重警告:千万别对大 CSV 文件用 low_memory=False。这个参数在 cuDF 里会关闭分块读取,直接吃满显存。我之前有个学员,一个 20GB 的 CSV 就这么读,结果把 T4 的 16GB 显存干爆了。

4.2 读取 Parquet:GPU 的最爱

我个人强烈推荐:只要可能,请用 Parquet。

Parquet 是列式存储,天生为 GPU 的并行处理设计的。cuDF 读 Parquet 比 Pandas 快 10-20 倍,这不算夸张。

# 读取单个 Parquet 文件
gdf_pq = cudf.read_parquet('features.parquet')

# 读取目录下的所有 Parquet(相当于一个数据集)
gdf_pq_dir = cudf.read_parquet('data/part-*.parquet')

你看看,代码就这么几行。但我要提醒你一个经验:Parquet 的分区列。

如果你的 Parquet 是按日期分区的(比如 data/date=2025-01-01/),cuDF 默认不会自动把分区目录名解析成列。你需要显式设置:

gdf = cudf.read_parquet('data/date=*/', 
                         columns=['user_id', 'score'],
                         filters=[('date', '>=', '2025-01-01')])

这样只读需要的列和分区,内存效率极高。

格式读取速度 (1GB 测试)显存占用推荐场景
CSV~500 MB/s高(需要整个解析)临时数据、小数据集
Parquet~4 GB/s低(列裁剪+压缩)生产环境、大数据集

速度差异为什么这么大?CSV 需要逐行解析、推断类型,这些在 CPU 上并行效率低。Parquet 自带 schema 和压缩,GPU 可以直接在列上并行解压——你想想看,一个 SM 处理一列,几十个 SM 同时开工,能不快吗?

4.3 数据类型:精准控制,别让 CUDA 报错

GPU 对数据类型极其敏感。我见过最多的报错就是 CUDA_ERROR_ILLEGAL_ADDRESS,原因往往是类型不匹配。

cuDF 支持的类型比 Pandas 多,但也更严格:

  • 整数:int8, int16, int32, int64
  • 浮点:float32, float64
  • 布尔:bool
  • 字符串:object (本质上是 GPU 上的列式字符串)
  • 时间:datetime64[ns]
  • 可空类型:int32? 表示可空整数(这个后面细说)

判断类型和转换类型:

# 检查类型
print(gdf['income'].dtype)  # float64

# 转换(注意:这是原地操作,返回新 DataFrame)
gdf['income'] = gdf['income'].astype('float32')

# 更高效的方式,如果你要转换多个列
gdf = gdf.astype({'income': 'float32', 'age': 'int32'})

嗯,这里要注意一点:不要用 pd.to_numeric 的等价写法。cuDF 没有直接的 to_numeric,你得用 .str.to_float() 或 .astype 配合错误处理。我之前试过直接用 astype 把包含 'NA' 的列转成 float,直接崩了……必须先用 na_values 或者 fillna。

技巧:如果你不确定数据里有没有脏值,可以先转为 object 类型,然后用 .str.replace() 清理,再转数值。比如:gdf['amount'] = gdf['amount'].str.replace(',', '').astype('float32')。这招在落地时非常实用。

4.4 缺失值处理:GPU 上的特殊玩法

在 GPU 上,缺失值的处理方式和 CPU 有本质区别。Pandas 里,缺失值用 NaN 或 None 表示。CuDF 里呢?

  • 数值列:用 (可空类型)或者 NaN(浮点)。
  • 整数列:默认不能有 NaN。如果你想在整数列里表示缺失,必须用可空类型,比如 int32? 或 Int32(注意大小写)。

先看看数据里有没有缺失值:

# 快速检查
print(gdf.isnull().sum())

# 如果你发现某列有很多缺失,比如 'age' 有 100 个 NaN
# 直接填充
gdf['age'] = gdf['age'].fillna(0)

填充的方法跟 Pandas 一样:fillna()、ffill()、bfill()。但!有一个性能陷阱:千万不要对整个 DataFrame 用 fillna(method='ffill')。这个操作在 GPU 上需要做一次全局同步,非常慢。

# 推荐:对单列操作
gdf['price'] = gdf['price'].ffill()  # 没问题

# 不推荐
gdf = gdf.ffill()  # 慢,因为要协调所有列
经验之谈:我一般在预处理阶段,先用 isnull().sum() 扫一遍,如果缺失值比例小于 5%,直接 dropna() 扔掉。如果超过 20%,我会根据业务逻辑填充。GPU 上的 dropna 其实很高效,因为它只需要筛选出有效的行,不用改数据。

删除缺失行:

# 删除任何包含缺失值的行
gdf_clean = gdf.dropna()

# 删除指定列中有缺失的行
gdf_clean = gdf.dropna(subset=['user_id', 'income'])

# 删除所有列都缺失的行(极少用)
gdf_clean = gdf.dropna(how='all')

你可能会问:为什么 cuDF 的 dropna 比 Pandas 快那么多?因为 GPU 可以并行地、按列地检查每个值是否为 null,然后生成一个布尔掩码,一次性地把有效行拷贝到新内存里。CPU 上则要逐行遍历,串行地读。

最后,把清理干净的数据保存起来——当然,用 Parquet:

gdf_clean.to_parquet('clean_data.parquet', index=False)

这样你的数据就已经在 GPU 上,准备好了下一步的降维操作。

GPU 数据加载与预处理流程 CSV / Parquet 读取 类型推断与转换 缺失值检测与填充 填充 (fillna) 删除 (dropna) 类型适配 (astype) 写入 Parquet / 送入模型 按列执行,避免全局同步