📎 配套代码:
第06讲_DataFrame操作.py
📊 配套数据:本讲用课件内的小样本(2024-01-02 真实收盘快照),无需外部数据
🎬 开场:真实数据从来不是一列
本讲样本是 2024-01-02 的真实收盘数据(取自本地 tushare
pro_bar):茅台 1685.01、五粮液 136.00、中国平安 39.47、平安银行 9.21、招商银行 27.58。只取五只是为了每个值都看得清。
上一讲的 Series 是一列带标签的数据。但你从数据源拿到的东西从来不长这样:
code name close vol industry 600519 贵州茅台 1685.01 32156 白酒 000858 五粮液 136.00 215269 白酒 601318 中国平安 39.47 437592 保险 000001 平安银行 9.21 1158366 银行
多个字段、类型还不一样:代码和名称是文本,价格是小数,成交量是整数。用 Series 装不下——它只有一列,而且一列只能一种类型。
DataFrame 就是为这个来的:行和列都有标签的二维表。
可以把它理解成”很多个 Series 拼在一起,共用同一套行标签”。上一讲学的取数、对齐、统计,在这里全都还能用,只是多了一个维度要交代。
这一讲把 DataFrame 的日常操作走一遍:建表、取数、增删改、筛选、排序、分组、合并、输出。
一、🧰 建表
从字典建(最常用)
df = pd.DataFrame({ "code": ["600519", "000858", "601318"], "name": ["贵州茅台", "五粮液", "中国平安"], "close": [1685.01, 136.00, 39.47], })
键成列名,值成整列数据。各列必须等长,否则报错。
指定行标签
不指定的话行标签是 0,1,2,...。多数时候你会想用有意义的东西当行标签:
df = df.set_index("code") # 把 code 这一列抬成行标签
name close code 600519 贵州茅台 1685.01 000858 五粮液 136.00 601318 中国平安 39.47
set_index 之后 code 就不再是普通列了,而是行标签。想还原用 reset_index()。
为什么要设行标签:设了之后就能按代码取行、两张表能自动对齐(下一讲的重点)。行标签是 DataFrame 的”主键”。
从记录列表建
pd.DataFrame([{"code": "600519", "close": 1685.01}, {"code": "000858", "close": 136.00}])
从 JSON API、数据库游标拿回来的数据常是这个形状,每个 dict 是一行。
从二维数组建
pd.DataFrame(np.random.randn(3, 2), columns=["ret", "vol"], index=["600519", "000858", "601318"])
算完的结果矩阵回填行列标签,走这条路。
先看一眼
拿到一张表,第一件事永远是这几个:
df.head() # 前 5 行 df.tail(3) # 后 3 行 df.shape # (行数, 列数) df.columns # 列标签 df.index # 行标签 df.dtypes # 每列什么类型 df.info() # 行列数 + 每列类型 + 非空计数 + 内存占用 df.describe() # 数值列的统计摘要
info() 和 describe() 是拿到陌生数据的标准动作:前者告诉你有多少缺失、类型对不对,后者告诉你数值范围合不合理。
🎮 随堂快练
QUESTION: 你从 CSV 读进来一张行情表,想用”代码”当行标签,并且先确认有没有缺失。写出这两步。
TIP: 👉 答案df = df.set_index("code") df.info() # 看 Non-Null Count 那一列 # 或者更直接 df.isna().sum() # 每列有几个缺失
二、🧰 取数:先说清楚你要行还是列
DataFrame 有两个维度,取数时最容易搞混的就是”我这句到底在取行还是取列”。
取列
df["close"] # 一列 → Series df"close", "vol" # 多列 → DataFrame(注意双层方括号) df.close # 属性写法,不推荐
裸方括号 df[...] 取的是列,这是要记住的第一条。
不推荐 df.close 是因为:列名带空格或和方法重名(比如有一列叫 count)时就不能用了,而且写代码时容易和真正的方法混淆。
取行
df.loc["600519"] # 按行标签 → Series df.iloc[0] # 按位置 → Series df.loc"600519", "601318" # 多行 → DataFrame df.iloc[0:3] # 切片 → DataFrame
同时指定行和列
df.loc["600519", "close"] # 一个值 df.loc["600519", ["close", "vol"]] # 一行的几列 df.loc[:, "close"] # 所有行的一列(等价于 df["close"]) df.iloc[0, 1] # 按位置,第 0 行第 1 列 df.iloc[0:3, 0:2] # 按位置切块
loc 和 iloc 里,逗号前是行、逗号后是列——和第 02 讲 numpy 的规矩一样。
⚠️ 取一行会丢类型
df.loc["600519"]
name 贵州茅台 close 1685.01 vol 32156 dtype: object ← 注意这里
一行里有文本、有小数、有整数,要塞进同一个 Series,只能退到 object 类型。所以:
df.loc["600519"]["close"] # ❌ 拿到的不再是 float64 df.loc["600519", "close"] # ✅ 一步到位,保持原类型
要取某行某列的值,用一步的 df.loc[行, 列],别分两步。 除了类型问题,分两步还可能触发另一个坑(下一讲的 SettingWithCopyWarning)。
取一列则不会有这个问题——一列本来就是同一种类型。
🎮 随堂快练
QUESTION: 下面四句各返回什么类型?
df["close"] df"close" df.loc["600519"] df.loc["600519", "close"]TIP: 👉 答案
df["close"]→ Series(一列)
df"close"→ DataFrame(只有一列的表,注意双括号)
df.loc["600519"]→ Series(一行,dtype 可能是 object)
df.loc["600519", "close"]→ 单个值(保持原类型)
单括号和双括号的区别在写函数时很要紧——下游期待 DataFrame 时给了 Series 就会报错。
三、🧰 增删改列
新增 / 修改
df["ret"] = df["close"].pct_change() # 由已有列算 df["flag"] = df["close"] > 100 # 布尔列 df["src"] = "wind" # 标量广播到每行
用 assign 链式新增
df = df.assign( mktcap = lambda x: x["close"] * x["vol"], logcap = lambda x: np.log(x["close"] * x["vol"]), )
assign 返回新表,适合写成一条链。用 lambda x: 引用当前表,这样即使前面的步骤改了表也不会引用到旧的。
⚠️ 同一个 assign 里不能引用刚生成的列(logcap 里不能用 mktcap),要分两次调用。
删除
df = df.drop(columns=["flag"]) # 删列,返回新表 df = df.drop(index=["000001"]) # 删行 del df["flag"] # 就地删列
推荐 drop——它返回新表,链式写法里更安全,也不会意外改到别人还在用的表。
改名
df = df.rename(columns={"close": "收盘价"}) df = df.rename(index={"600519": "茅台"}) df.columns = ["a", "b", "c"] # 整体替换(要求个数对上)
调整列顺序
df = df"name", "close", "vol" # 直接按你要的顺序取列
🎮 随堂快练
QUESTION: 给表加两列:市值(收盘价×成交量)和市值的对数。写出正确的
assign写法。
TIP: 👉 答案df = (df.assign(mktcap=lambda x: x["close"] * x["vol"]) .assign(logcap=lambda x: np.log(x["mktcap"])))必须分成两个
assign——第二个才能引用第一个生成的mktcap。写在同一个assign里会报KeyError。
四、🧰 筛选行
布尔条件
和第 04 讲一样,只是现在筛的是行:
df[df["close"] > 100] df[(df["close"] > 100) & (df["industry"] == "白酒")]
多个条件记得用 & | ~ 并给每个条件加括号——第 04 讲那两个坑在这里完全一样。
query:把条件写成字符串
df.query("close > 100") df.query("close > 100 and industry == '白酒'") df.query("close > @threshold") # @ 引用外部变量
query 的好处是条件长的时候更好读——不用重复写 df[...],也不用担心括号。而且它支持 and / or 这些自然写法。
两种写法都常见,选哪个看条件复杂度:短条件用布尔,长条件用 query。
按标签筛选
df.loc"600519", "601318" # 指定几只 df[df.index.str.startswith("60")] # 标签满足某个模式 df[df["industry"].isin(["白酒", "银行"])] # 某列属于某个集合
处理缺失
df.dropna() # 有任何缺失的行都丢掉 df.dropna(subset=["close"]) # 只看 close 这列 df.fillna(0) # 全部填 0 df["close"].fillna(method="ffill") # 某列前向填充
⚠️ dropna() 默认是”这一行有任何一个缺失就丢”,在宽表上会丢掉大量数据。多数时候你只关心几个关键列,用 subset 限定范围。
🎮 随堂快练
QUESTION: 筛出”白酒或银行行业、且收盘价大于 50″的股票。用两种写法。
TIP: 👉 答案df[df["industry"].isin(["白酒","银行"]) & (df["close"] > 50)] df.query("industry in ['白酒','银行'] and close > 50")布尔写法要注意
isin(...)返回的已经是布尔 Series,和后面用&连接时后者要加括号。
五、🧰 排序与排名
df.sort_values("close") # 按一列升序 df.sort_values("close", ascending=False) # 降序 df.sort_values(["industry", "close"]) # 先按行业、再按价格 df.sort_index() # 按行标签排 df.nlargest(3, "close") # 最大的 3 行 df.nsmallest(3, "close") df["close"].rank(ascending=False) # 排名,1 是最大的 df["close"].rank(pct=True) # 百分位(0~1)
nlargest(n, 列名) 比”排序再切片”更直接,选股时常用。
排名在因子研究里比排序用得更多:rank(pct=True) 把原始值转成 0~1 的百分位,PE、换手率、市值这些量纲完全不同的指标转完就可比了。
NOTE: 💡 排序的坑集中在第 09 讲
这里只把写法列全。缺失值排在哪、并列名次怎么处理、以及「排序没做对导致后面结果全错」这类不报错的坑,都和缺失数据咬在一起——排序本质是一连串比较,而NaN参与比较一律是False。这些放在第 09 讲一并展开。
六、🧰 分组统计
df.groupby("industry")["close"].mean() # 各行业平均价 df.groupby("industry")["close"].agg(["count", "mean", "std"]) df.groupby("industry").agg({"close": "mean", "vol": "sum"})
count mean industry 保险 1 39.470 白酒 2 916.15 银行 1 9.210
分组是数据分析里最常用的操作之一——按行业、按日期、按市值分档统计。这里先会用,第 15–17 讲会展开讲。
transform:结果贴回原表
agg 会把每组压成一个值(表变短了)。如果你想给每一行都配上它所属组的统计量,用 transform:
df["行业均价"] = df.groupby("industry")["close"].transform("mean") df["超额"] = df["close"] - df["行业均价"]
transform 返回的长度和原表一致,可以直接赋值成新列。做行业中性化时这是标准写法。
🎮 随堂快练
QUESTION: 算每只股票的收盘价相对其所属行业均价的偏离百分比。
TIP: 👉 答案ind_mean = df.groupby("industry")["close"].transform("mean") df["偏离"] = df["close"] / ind_mean - 1用
transform而不是agg——前者长度和原表一致,能直接参与逐行计算。
七、🧰 合并两张表
按键合并:merge
pd.merge(price, info, on="code", how="left")
how 决定保留哪边的行:
| how | 保留 |
|---|---|
left |
左表全部,右表匹配不上的补 NaN |
right |
右表全部 |
inner |
只保留两边都有的(默认) |
outer |
两边全要 |
⚠️ 合并后一定要看行数:
before = len(price) merged = pd.merge(price, info, on="code", how="left") print(before, len(merged))
行数变多说明右表有重复键(一对多变成了多对多);用 inner 时行数变少说明有对不上的。两种情况都不报错。
按标签拼接:concat
pd.concat([df1, df2]) # 上下摞(加行) pd.concat([df1, df2], axis=1) # 左右拼(加列,按行标签对齐)
concat(axis=1) 是批量加列的推荐写法——比在循环里一列列 df["x"] = ... 更好,也更快。
🎮 随堂快练
QUESTION: 你要把行情表和行业分类表合起来,要求保留行情表的所有股票(哪怕没有行业信息)。写出代码,并说明合并后要检查什么。
TIP: 👉 答案merged = pd.merge(price, industry, on="code", how="left") assert len(merged) == len(price) # 行数不该变 merged["industry"].isna().sum() # 有几只没匹配到行业
how="left"保证行情表的股票一只不少。两个检查缺一不可:行数变了说明分类表有重复代码;NaN 数量告诉你有多少只缺分类。
八、🧰 输出
df.to_csv("out.csv") # 存 CSV(带行标签) df.to_csv("out.csv", index=False) # 不要行标签 df.to_parquet("out.parquet") # 二进制格式,更快更小 df.to_excel("out.xlsx")
存中间结果推荐 parquet——它保留 dtype(CSV 会把所有东西变成文本,读回来类型全丢),文件也小很多。给别人看再用 CSV 或 Excel。
🏋️ 训练营
QUESTION: 🟢 训练 1:给定一张有
code / name / close / vol / industry的表(行标签是 code)。写出:① 收盘价最高的 3 只 ② 白酒行业的全部股票 ③ 新增一列市值 ④ 按行业统计平均收盘价
TIP: 👉 参考df.nlargest(3, "close") df[df["industry"] == "白酒"] df["mktcap"] = df["close"] * df["vol"] df.groupby("industry")["close"].mean()
QUESTION: 🟡 训练 2:做一个简单的行业中性化——把每只股票的市值减去其所属行业的市值均值,再除以行业标准差。写出代码。
TIP: 👉 参考g = df.groupby("industry")["mktcap"] df["neutral"] = (df["mktcap"] - g.transform("mean")) / g.transform("std")两次
transform都返回和原表等长的结果,所以能直接做逐行运算。
⚠️ 只有一只股票的行业,std会是 NaN(样本标准差需要至少两个观测)。真实数据里要么把这类行业剔掉,要么用全市场标准差兜底。
QUESTION: 🔴 训练 3:下面这段代码合并两张表后统计,结果不对。找出两个问题。
merged = pd.merge(price, info, on="code") # ① merged["mktcap"] = merged["close"] * merged["shares"] top = merged.sort_values("mktcap")[:10] # ② print(f"市值前十: {top['name'].tolist()}")TIP: 👉 参考
问题①:merge没写how,默认是inner——只保留两边都有的股票。如果info表缺了几只,它们就被静默丢掉了,而你以为统计的是全市场。
→ 改成how="left"并检查len(merged) == len(price)。
问题②:sort_values默认升序,[:10]取到的是市值最小的十只。
→ 改成merged.nlargest(10, "mktcap"),既修了方向,也把意图写清楚了。
两个问题都不报错,输出的还是十个正常的股票名字——这正是数据处理最典型的出错方式。
🐛 常见坑
- ⚠️
df[...]取的是列,df.loc[...]取的是行:搞混会得到莫名其妙的结果或KeyError。 - ⚠️ 取一行会退成 object:
df.loc[i]["col"]类型丢失。用一步的df.loc[i, "col"]。 - ⚠️ 单括号 vs 双括号:
df["a"]是 Series,df"a"是 DataFrame。 - ⚠️
sort_values默认升序:想要”最大的几个”要么加ascending=False,要么直接用nlargest。 - ⚠️
merge默认inner:会静默丢掉对不上的行。合并后一定核对行数。 - ⚠️
dropna()默认丢掉有任何缺失的整行:宽表上会误删大量数据,用subset限定关键列。 - ⚠️
assign里不能引用刚生成的列:要分两次调用。 - ⚠️ 循环里逐列加:
for ... : df[f"x{i}"] = ...又慢又难读。攒成字典后concat(axis=1)。
✍️ 作业
- 用四种方式各建一次 DataFrame(字典、记录列表、二维数组、从 CSV 读),对每个都跑一遍
info()和describe()。 - 造一张有 5 只股票、5 个字段的表,练习取数:取一列、取多列、取一行、取一个值、取一块。把每次结果的类型打出来,确认哪些是 Series、哪些是 DataFrame。
- 验证”取一行会退成 object”:对比
df.loc[i]["close"]和df.loc[i, "close"]的类型。 - 造两张有部分重叠代码的表,分别用
how的四种取值合并,记录每次的行数,解释差异。 - 用
groupby+transform做一次行业中性化,并检查有多少行结果是 NaN、为什么。
🔮 下讲预告:第 07 讲——apply。前三讲用的都是 pandas 给好的函数,但研究里总有些逻辑是内置函数覆盖不到的:按交易所规则给代码加后缀、按自定义规则分档、每只股票单独跑一次回归。下一讲讲怎么把自己写的函数用到数据上,以及一个同样重要的问题:什么时候不该用它——
apply太方便了,方便到你会拿它去做本来有更好写法的事。