第06讲 DataFrame 带行列标签的表格

📎 配套代码第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]                      # 按位置切块

lociloc 里,逗号前是行、逗号后是列——和第 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
  • ⚠️ 取一行会退成 objectdf.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)

✍️ 作业

  1. 用四种方式各建一次 DataFrame(字典、记录列表、二维数组、从 CSV 读),对每个都跑一遍 info()describe()
  2. 造一张有 5 只股票、5 个字段的表,练习取数:取一列、取多列、取一行、取一个值、取一块。把每次结果的类型打出来,确认哪些是 Series、哪些是 DataFrame。
  3. 验证”取一行会退成 object”:对比 df.loc[i]["close"]df.loc[i, "close"] 的类型。
  4. 造两张有部分重叠代码的表,分别用 how 的四种取值合并,记录每次的行数,解释差异。
  5. groupby + transform 做一次行业中性化,并检查有多少行结果是 NaN、为什么。

🔮 下讲预告:第 07 讲——apply。前三讲用的都是 pandas 给好的函数,但研究里总有些逻辑是内置函数覆盖不到的:按交易所规则给代码加后缀、按自定义规则分档、每只股票单独跑一次回归。下一讲讲怎么把自己写的函数用到数据上,以及一个同样重要的问题:什么时候不该用它——apply 太方便了,方便到你会拿它去做本来有更好写法的事。


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