跳到正文

Pandas数据读取与写入:read_csv、Excel 与数据库

做分析的第一步不是建模,是把数据完整地读进来。读进来那一刻列的 dtype 就定了,后面每一步都在这个基础上做:本该是数值的列读成了字符串,求均值时要么报错要么给出离谱结果;日期列读成了文本,画时间序列之前还得再转一次。read_csv 的参数有几十个,日常真正用到的不到十个。

用 sklearn 自带的 iris 数据集走一遍完整流程。load_iris(as_frame=True) 直接返回 DataFrame,不需要自己拼列名。

import pandas as pd
from sklearn.datasets import load_iris
iris = load_iris(as_frame=True)
df = iris.frame
df.to_csv("iris.csv", index=False) # 写到当前目录
df2 = pd.read_csv("iris.csv") # 再读回来
print(df2.shape)
print(df2.dtypes)
(150, 5)
sepal length (cm) float64
sepal width (cm) float64
petal length (cm) float64
petal width (cm) float64
target int64
dtype: object

这些 dtype 是推断出来的:pandas 逐列扫描文本,看到 5.1 就认成浮点,看到 0 就认成整数。推断经常出错,出错的方式还很隐蔽。学号 007 读进来是整数 7,前导零没了;身份证号、手机号这类十几位的列推断成 int64 之后,一旦有缺失值整列会变成 float64,值还会显示成 1.234567890123457e+17编号、编码、邮编这类列,读取时就显式指定 dtype=str,别等到发现对不上再回头改。

usecolsdtype 是最值得先记住的两个参数。150 行的 iris 用不用它们没差别,几 GB 的问卷导出文件能省掉一半以上的内存和读取时间。

small = pd.read_csv(
"iris.csv",
usecols=["sepal length (cm)", "target"],
dtype={"target": "int8"},
)
print(small.head(3))
print(small.dtypes)
sepal length (cm) target
0 5.1 0
1 4.9 0
2 4.7 0
sepal length (cm) float64
target int8
dtype: object

target 只有 0、1、2 三个取值,用 int8 就够,读成 int64 是八倍的空间浪费。做交叉表、分组统计时这点空间不算什么,但如果这张表有几十列分类变量,差别就很明显了。

真实文件很少是干净的逗号分隔。分号分隔在欧洲软件导出的 CSV 里很常见,缺失值可能是空串、NA-null 里的任意一种。为了不依赖外部文件,下面用 io.StringIO 把一段文本当文件读。

import io
import pandas as pd
txt = "id;name;value;date\n1;alpha;NA;2024-01-02\n2;beta;3.5;2024-02-03\n3;gamma;-;2024-03-04\n"
d = pd.read_csv(io.StringIO(txt), sep=";", na_values=["NA", "-"], parse_dates=["date"])
print(d)
print(d.dtypes)
id name value date
0 1 alpha NaN 2024-01-02
1 2 beta 3.5 2024-02-03
2 3 gamma NaN 2024-03-04
id int64
name str
value float64
date datetime64[us]
dtype: object

不用 na_values 的话,- 会被当成普通字符串,整列 value 变成字符串类型,后面所有数值计算全部失效。parse_dates 则是把日期字符串转成 datetime64——只有转成了日期类型,.dt.month、按时间重采样、时间序列图才可用。这里显示成 datetime64[us](微秒精度),pandas 2.x 及更早版本会显示 datetime64[ns],只是精度标注不同,不影响使用。

国内软件(Excel、SPSS、各类问卷平台)导出的 CSV 大多是 GBK 编码,直接读会抛错:

import pandas as pd
cn = pd.DataFrame({"姓名": ["张三", "李四", "王五"], "成绩": [88.5, 92.0, 79.5]})
cn.to_csv("成绩.csv", index=False, encoding="gbk") # 模拟一份 GBK 文件
print(pd.read_csv("成绩.csv", encoding="gbk")) # 指定编码,正常读出
姓名 成绩
0 张三 88.5
1 李四 92.0
2 王五 79.5

不指定编码会看到这样的报错,栈信息里通常只有一行有用:

try:
pd.read_csv("成绩.csv")
except UnicodeDecodeError as e:
print("UnicodeDecodeError:", e)
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xd0 in position 0: invalid continuation byte

0xd0 是 GBK 里「姓」的半个字节。看到 UnicodeDecodeError,把 encoding 依次试 gbkgb18030utf-8-sig 基本能解决。gb18030 是 GBK 的超集,遇到更生僻的少数民族文字或繁体字时用它更保险。

反方向也有坑:用 UTF-8 写的中文 CSV 双击用 Excel 打开是乱码,因为 Excel 默认按本地编码解析。要让 Excel 认出来,写成 encoding="utf-8-sig",它会在开头加三个字节的 BOM 标记。

read_excel 本身不含解析器,读 .xlsx 需要装 openpyxl,读旧版 .xls 需要 xlrd

终端窗口
$ pip install openpyxl

没装的话 pd.read_excel() 会直接抛 ImportError。除了这一点,接口和 read_csv 几乎一样,usecolsdtypena_values 都能用:

df.to_excel("iris.xlsx", index=False)
x = pd.read_excel("iris.xlsx", sheet_name="Sheet1", usecols=["sepal length (cm)", "petal length (cm)"])
print(x.head(3))
sepal length (cm) petal length (cm)
0 5.1 1.4
1 4.9 1.4
2 4.7 1.3

sheet_name 除了填表名,也可以填工作表序号(从 0 开始),填 None 则一次读回所有工作表,返回一个字典。多工作表的工作簿用 pd.read_excel("f.xlsx", sheet_name=None) 一次拿全,比逐个表名试要快。

数据库走 read_sql,推荐配一个 SQLAlchemy 引擎。SQLite 也能直接传 sqlite3.connect() 拿到的连接对象,但那种写法把代码绑死在 SQLite 上,换库就要改代码;用 create_engine 加连接串,换数据库只改一行 URL(对应的驱动包还是要装):

from sqlalchemy import create_engine
engine = create_engine("sqlite://") # 内存库,换成真实连接串即可
df.to_sql("iris", engine, index=False, if_exists="replace")
q = pd.read_sql(
'SELECT target, AVG("sepal length (cm)") AS mean_sepal, COUNT(*) AS n '
"FROM iris GROUP BY target",
engine,
)
print(q)
target mean_sepal n
0 0 5.006 50
1 1 5.936 50
2 2 6.588 50

能推给数据库的活就别拉到内存里做。几千万行的表在 SQL 里 GROUP BY 只要几秒,读成 DataFrame 再 groupby 可能直接把内存撑爆。判断标准很简单:能用一条 SQL 表达清楚的聚合,就在数据库里做

这是新手最容易踩的一个。to_csv() 默认把行索引也写进去,读回来就多出一列 Unnamed: 0

df.to_csv("iris_index.csv") # 忘了 index=False
back = pd.read_csv("iris_index.csv")
print(list(back.columns))
print(back.head(2))
['Unnamed: 0', 'sepal length (cm)', 'sepal width (cm)', 'petal length (cm)', 'petal width (cm)', 'target']
Unnamed: 0 sepal length (cm) ... petal width (cm) target
0 0 5.1 ... 0.2 0
1 1 4.9 ... 0.2 0
[2 rows x 6 columns]

(列太多时 pandas 会省略中间几列,用 ... 占位,并在末尾给出形状。上面的输出说明这张表比 iris 原表多了一列。)

后果不是多一列那么轻。下次把这张表合并给别人,对方在这个 Unnamed: 0 上做连接,两边索引规则稍有不同就会得到错误结果,而且很难看出来。写到 CSV、Excel 一律加 index=False,除非你明确要用索引当一列数据。

读进来发现有 Unnamed: 0,两种处理都行:pd.read_csv("f.csv", index_col=0) 把它当索引读回去,或者读完之后 df = df.drop(columns="Unnamed: 0")

文件比内存大的时候,read_csv 会直接 MemoryErrorchunksize 返回一个可迭代的读取器,每批给你指定行数:

total = 0
for chunk in pd.read_csv("iris.csv", chunksize=60):
total += len(chunk) # 每批只处理 60 行,处理完就释放
print(total)
150

分块适合「逐行过滤、逐批聚合」这类任务,比如从日志里挑出符合条件的两万行存成新文件。不适合需要全表排序、透视的操作——那种情况该换 DuckDB、Polars 或者直接丢给数据库。

数据读进来之后,下一步是把它筛成你要的那部分,见 Pandas数据筛选与选择。读取方式本身也是可重复研究的一部分:把路径、编码、dtype 都写进脚本,而不是手工在 Excel 里改完再另存一份。R 语言里 可重复研究 讲依赖管理的那部分思路完全通用,值得一并看看。