📎 配套代码:
第10讲_转换与字符串.py
📊 配套数据:data/quote/data/industry/—— 交易数据、行业数据(沪深300 × 2024 年以来,仓库自带,开箱即跑)
🎬 开场:两张表按代码合并,一条都没对上
手上两份数据,都是 A 股股票,都有代码列:
a = sb"ts_code", "name", "industry" # tushare:ts_code 形如 000001.SZ b = other"code", "list_date" # 另一个源:code 形如 000001 a.merge(b, left_on="ts_code", right_on="code", how="inner")
真跑:
表A 1000 行(ts_code 形如 000001.SZ) 表B 1000 行(code 形如 000001) 直接 merge → 0 行 ← 一条都没对上
merge 没有报错,没有警告,规规矩矩地返回了一个空表。因为在字符串比较的意义上,"000001.SZ" 和 "000001" 就是两个毫不相干的值。
统一格式之后:
a["code"] = a["ts_code"].str.split(".").str[0] a.merge(b, on="code") # → 1000 行
真实世界里的数据几乎从不是你想要的样子。代码带不带交易所后缀、公司名带不带 *ST 前缀、日期是 20240102 还是 2024-01-02、行业分成 110 类而你只想要 5 个大类——每一次跨数据源的工作,开头都是一段格式统一。
上一讲处理的是”值缺了”,这一讲处理的是”值有,但不是你要的形状”。
🎯 这一讲结束时,你能
- 用
duplicated/drop_duplicates查重去重,并知道量化数据判重的键该怎么选 - 用
.str方法拆装股票代码、识别剥离ST前缀、用正则从文本里抽字段 - 分清
map/replace/rename各管什么,并避开map未覆盖值静默变NaN的坑 - 用
cut和qcut分箱,并说清为什么因子分组几乎总是用qcut - 用
get_dummies造行业哑变量 - 让字符串操作快三倍、内存省七成——只改一行
一、🧰 先查重复
跨源合并、多文件拼接、增量更新,都可能引入重复行。动手改数据之前先查一遍。
df.duplicated() # 布尔掩码,从第 2 次出现起为 True df.duplicated().sum() # 有多少重复行 df.drop_duplicates() # 删掉重复行 df.drop_duplicates(subset=["ts_code"]) # 只按这一列判重 df.drop_duplicates(subset=["ts_code"], keep="last") # 保留最后一次
keep 三个取值:"first"(默认,留第一次)、"last"(留最后一次)、False(重复的全删,一个不留)。
量化数据判重,键要选对
sb.duplicated().sum() # 0 sb.duplicated("ts_code").sum() # 0 px.duplicated(["ts_code", "trade_date"]).sum() # 0 px.duplicated("ts_code").sum() # 171,047 ← 键选错了
这份数据很干净,前三项都是 0。但最后一行值得看:同一份 17 万行的行情表,只按 ts_code 判重会认为 17 万行里有 17 万行是重复的——按这个结果去重,300 只股票每只只剩一行。
判重的键要选对:
-
基础信息表(一只股票一行)按
ts_code判重 -
行情表(一只股票每天一行)必须按
["ts_code", "trade_date"]判重
行情表上只按 ts_code 判重是个严重错误——同一只股票本来就有几千行,那样会把它的历史删得只剩一天。判重键要能唯一确定一行。
TIP: 🚀 增量更新后固定查一次
每天往本地库追加数据时,最常见的事故是同一天的数据被追加了两次(脚本重跑、任务重试)。assert not df.duplicated(["ts_code", "trade_date"]).any(), "有重复行"重复行不会报错,但会让成交额翻倍、让分组统计的计数对不上。
🎮 随堂快练
QUESTION: 一张订单表,同一个
order_id因为重试出现了多行,你想每个order_id只留最新的一条。怎么写?
TIP: 👉 答案df.sort_values("time").drop_duplicates("order_id", keep="last")两步缺一不可:
keep="last"取的是当前行顺序里的最后一行,不是时间上最新的一行。所以必须先sort_values("time")保证行顺序就是时间顺序——这和第 09 讲groupby前要排序是同一件事。
二、🧰 字符串处理:.str
pandas 把字符串方法整列包装成 s.str.xxx(),用法和 Python 原生的字符串方法基本一致,但自动跳过缺失(NaN 原样保留,不报错)。
s.str.strip() s.str.lower() s.str.upper() s.str.len() s.str.startswith("*") s.str.endswith(".SH") s.str.contains("ST") s.str.split(".") s.str.cat(sep="") s.str.replace(...) s.str.extract(...) s.str.zfill(6) s.str[0] s.str[:3] # 像切片一样按位置取字符
下面用真实的 stock_basic 表(5519 只 A 股)走四个常见任务。
任务一:拆掉交易所后缀
sb["ts_code"].str.split(".").str[0] # 000001.SZ → 000001
.str.split(".") 把每格切成一个列表,后面再跟一个 .str[0] 取列表的第 0 个元素。.str 可以链式使用,第二个 .str 作用在列表上。
也可以用 expand=True 一步切成两列:
sb["ts_code"].str.split(".", expand=True) # 得到两列:代码、后缀
任务二:反过来,给纯数字代码补后缀
后缀规则不用查文档,从真实数据里反推出来。把首位数字和后缀交叉统计:
pd.crosstab(sb["ts_code"].str[0], sb["ts_code"].str.split(".").str[1])
suffix BJ SH SZ prefix 0 0 0 1494 3 0 0 1398 6 0 2313 0 9 314 0 0
规则一目了然:6 开头去上交所,0 和 3 开头去深交所,9 开头是北交所,没有任何交叉。
按这个规则补后缀:
sym = sb["symbol"] sb["rebuilt"] = np.select( [sym.str[0] <mark> "6", sym.str[0].isin(["0", "3"]), sym.str[0] </mark> "9"], [sym + ".SH", sym + ".SZ", sym + ".BJ"], default=sym)
按首位数字补后缀,与原 ts_code 一致的比例: 100.00%
5519 只股票全部对上。注意这里用的是 np.select 而不是 apply——三个条件三个结果,全程向量化(第 07 讲)。
WARNING: ⚠️ 从数据反推规则,也要验证
上面这个 100% 是在这份数据上成立的。历史退市股、B 股、以及未来可能出现的新板块都不在这 5519 只里。
写完这类规则,加一句default=兜底并检查兜底命中了多少——别让规则之外的数据静默地保持原样。
任务三:识别和剥离 ST 前缀
ST 是风险警示标记,做股票池时通常要剔除。
sb["name"].str.contains("ST", na=False) # 含 ST 的 sb["name"].str.startswith("*ST", na=False) # 以 *ST 开头的
含 ST 的 255 只,例 ['*ST国华', '*ST美丽', '*ST康佳A', '*ST皇庭'] 以 *ST 开头 148 只
两个数字不一样:*ST(退市风险警示)148 只,还有一批是 ST(其它风险警示)。做股票池过滤时用 contains("ST") 把两类都包住。
na=False 这个参数很重要:不写的话,缺失行的结果是 NaN 而不是 False,拿去做布尔索引会报错。
剥掉前缀:
sb["name"].str.replace(r"^\*?ST", "", regex=True)
['国华', '美丽', '康佳A', '皇庭']
正则 ^\*?ST 的意思是:开头、可能有一个 *(\* 是转义后的字面星号,? 表示可有可无)、然后是 ST。
任务四:用正则抽字段
list_date 是 "19910403" 这样的字符串,只要年份:
sb["list_date"].str.extract(r"^(\d{4})").astype("Int64")
[1991, 1991, 1990] dtype=Int64
上市年份 top5: {2021: 523, 2020: 432, 2017: 432, 2022: 428, 2010: 322}
extract 用捕获组 () 抽内容,有几个组就返回几列。抽不到的行给 NaN。
末尾的 .astype("Int64") 是第 09 讲那个可空整型——年份是整数,而抽不到的行会是缺失,普通 int64 装不下缺失。
regex 参数必须显式写
s.str.replace(".", "", regex=False) # 删掉字面的点 s.str.replace(".", "", regex=True) # 正则里 . 匹配任意字符 → 整列清空
同一行代码,regex 取值不同,结果天差地别。pandas 2.x 里 .str.replace 默认 regex=False,但默认值在历史版本里变过,依赖它很危险。
IMPORTANT: 🔑 养成显式写
regex=的习惯
正则元字符. * + ? ( ) [ ] { } | ^ $ \在字面替换时都要小心。
判断方法:你的 pattern 里如果出现了这些字符,就必须想清楚是当字面量还是当正则。当字面量用regex=False,或者用re.escape()转义。
🎮 随堂快练
QUESTION: 一列股票代码是整数
1、858、600519(第 08 讲那个前导零被吃掉的表)。怎么恢复成 6 位字符串?
TIP: 👉 答案s.astype(str).str.zfill(6) # → '000001', '000858', '600519'
zfill(6)在左边补零到 6 位。先astype(str)是因为.str只能作用在字符串上。
这是补救措施——更好的做法是一开始就用dtype={"code": str}别让它变成整数。恢复得了前导零,恢复不了别的信息损失。
三、🧰 改值:map / replace / rename
三个方法都在”改东西”,但改的对象完全不同:
| 方法 | 改什么 | 典型用法 |
|---|---|---|
map |
Series 的每个值,按字典或函数逐个映射 | 行业代码 → 行业名称 |
replace |
特定的几个值,出现在哪儿都换 |
-999 → NaN
|
rename |
轴标签(行名、列名),数据不动 | 列名统一成小写 |
df["行业名"] = df["行业代码"].map(code_to_name) # map:整列查表 df.replace([-999, -1000], np.nan) # replace:换哨兵值 df.rename(columns={"ts_code": "code"}) # rename:改列名 df.rename(columns=str.lower) # rename 也吃函数
map 最大的坑:没覆盖到的值静默变成 NaN
industry 列有 110 个不同的值。假设你只写了一张两条的映射表:
mp = {"银行": "金融", "全国地产": "地产"} sb["industry"].map(mp)
真跑:
industry 共 110 类,映射表只写了 2 类 map 后缺失 5,451/5,519 行 (98.8%) ← 未覆盖的值静默变 NaN
98.8% 的数据变成了缺失,没有任何报错。
映射表漏几个类别是极常见的事——行业分类会更新、数据源会新增代码、你写映射表时手上的样本不全。而 map 对没查到的值一律返回 NaN,不会告诉你漏了什么。
三种兜底方式:
sb["industry"].map(mp).fillna(sb["industry"]) # ① 没映射到的保持原值 sb["industry"].map(mp).fillna("其它") # ② 没映射到的归入"其它" sb["industry"].replace(mp) # ③ replace 天然保持原值
用 fillna 兜底后缺失 1 行 ← 剩下的 1 行是原始数据里 industry 本来就缺
IMPORTANT: 🔑
map和replace在”没查到”时行为相反
map把没查到的变NaN;replace把没查到的原样保留。
想做”完整替换、剩下的丢弃”用map;想做”部分替换、其余不动”用replace。选错的后果是静默丢数据。
用map之前查一句:set(df["col"]) - set(mp)看看有哪些值没被覆盖。
🎮 随堂快练
QUESTION: 你要把 110 个细分行业归并成 5 个大类,但只整理出了其中 80 个的对应关系。用
map还是replace?
TIP: 👉 答案
都不理想,取决于你想怎么处理剩下的 30 个:
- 想让它们显式变成”未分类”:
map(mp).fillna("未分类")——这样它们在后续分组里是一个可见的类别,你能看到有多少。- 想让它们保持细分行业名:
replace(mp)——但结果会混着大类和细分类,分组时会出现 35 个组,很容易被忽略。
推荐第一种。归并这类操作,剩余项应该是显式可见的,而不是混在结果里。
四、🧰 分箱:cut 和 qcut
把连续数值切成几档,是因子研究的基本动作——市值分五档看规模效应、按因子值分十档看单调性。
-
cut:你给边界,按数值区间切。每档数量可能天差地别。 -
qcut:按分位数切,每档数量大致相等。
pd.cut(x, [0, 50, 100, 500]) # 按给定边界 pd.cut(x, 5) # 等宽切 5 段 pd.cut(x, bins, labels=["小", "中", "大"]) # 给每档命名 pd.qcut(x, 5) # 五分位,每档 20% pd.qcut(x, [0, .1, .5, .9, 1]) # 自定义分位点
在真实市值数据上,两者差距惊人
取某个交易日的截面(300 只股票),分别用两种方式切 5 档:
pd.qcut(db["total_mv"], 5, labels=["最小","小","中","大","最大"]).value_counts() pd.cut(db["total_mv"], 5).value_counts()
真跑:
qcut 五分位每档只数: [60, 59, 60, 59, 60] cut 等宽五档每档只数: [280, 10, 2, 3, 3]
cut 的第一档装了 280 只,最后三档合计只有 8 只。
原因是市值的分布极度右偏:绝大多数公司市值几十亿,少数几家上万亿。等宽切分把”0 到最大值”平均分成 5 段,几乎所有公司都落在第一段里。
IMPORTANT: 🔑 因子分组几乎总是用
qcut
因子研究要比较”高因子值组 vs 低因子值组”的收益差,前提是每组样本量相当。cut切出来一组 280 只、一组 2 只,后者的组合收益率完全是噪声。
财务和市场数据里,市值、成交额、PE、PB 全都是右偏的。用cut之前先看一眼分布,x.describe()里如果均值远大于中位数,就说明右偏,别用等宽。
反过来,cut适合边界有业务含义的场景——涨跌幅按 ±3%/±7%/±10% 分档,边界对应的是涨跌停和常见阈值,不是分位数。
qcut 的重复边界问题
当数据里有大量重复值时,分位点可能落在同一个数上:
z = pd.Series([0]*100 + list(range(50))) # 一大堆 0 pd.qcut(z, 5)
ValueError: Bin edges must be unique: Index([0.0, 0.0, 0.0, 0.0, 19.2...
五分位里有四个都是 0,边界重复了。解法是允许合并:
pd.qcut(z, 5, duplicates="drop").nunique() # → 2 档
要求 5 档,实际只切出 2 档。这个结果本身是个信号——数据里重复值太多,分箱这个操作对它可能就不合适。量化里常见于成交量为 0(停牌)、换手率为 0 的场景。
分箱结果是 Categorical
pd.qcut(db["total_mv"], 5).dtype # category
分箱天然就是”少数几个档位 + 每个值属于哪一档”,正好是 Categorical 的结构。第 11 讲会讲它为什么省内存、为什么当 groupby 的键特别快。
🎮 随堂快练
QUESTION: 你要按 PE 把股票分十档做因子回测。数据里 22% 的 PE 是缺失(第 09 讲那批亏损股)。
qcut会怎么处理?
TIP: 👉 答案
缺失行的分档结果是NaN,不参与分位数计算。 所以十档是在 78% 的有效样本里切的,每档约 7.8% 的全样本。
这通常正是你要的——亏损股本来就不该进 PE 分档。但要意识到两件事:① 你的因子只覆盖了 78% 的股票;② 后续groupby分档统计时,那 22% 会被自动排除,组合的实际持仓数量和你以为的不一样。把这个数报出来,而不是让它隐在背后。
五、🧰 哑变量:get_dummies
把一列类别摊成”每个类别一列 0/1″:
pd.get_dummies(sb["market"], prefix="板")
形状 (5519, 4),列 ['板_主板', '板_创业板', '板_北交所', '板_科创板']
各板数量 {'板_主板': 3198, '板_创业板': 1398, '板_北交所': 314, '板_科创板': 609}
量化里最常见的用途是行业中性化:把行业哑变量作为回归的控制变量,剥离掉因子收益里属于行业的部分。
ind = pd.get_dummies(df["industry"]) # 110 列 X = pd.concat([df"factor", ind], axis=1) # 因子 + 行业哑变量一起进回归
如果一格里是多个值("新能源|汽车|电池" 这种概念标签),用 str.get_dummies:
df["concepts"].str.get_dummies(sep="|")
NOTE: 💡 哑变量陷阱:完全共线
110 个行业造出 110 列,任意一行这 110 列之和恒等于 1——它们线性相关,直接全部放进回归会导致矩阵不可逆。
标准做法是drop_first=True去掉一列作为基准组,或者回归时不带截距项。做因子中性化时这一步经常被忽略,然后困惑于为什么回归结果不稳定。
六、🔬 让字符串操作快三倍:改一行
.str 用起来像向量化,但在默认的 object 类型上它并不是。这一节值得讲,因为结论是一行可执行的改动。
object 和 pyarrow 存字符串的方式不同
object dtype:
一个指针数组(连续)
┌────┬────┬────┬────┐
│ p0 │ p1 │ p2 │ p3 │ 每格 8 字节,是个指针
└─┬──┴─┬──┴─┬──┴─┬──┘
↓ ↓ ↓ ↓
散落在内存各处的 Python str 对象,每个都带对象头(引用计数、类型指针、长度…)
string[pyarrow]:
一整块连续的 UTF-8 字节: 平安银行万科A*ST国华深振业A...
一个 offsets 数组标起止: [0, 12, 21, 33, ...]
object 存的是指针,真正的字符串对象散落在内存各处、大小不一。想批量处理必须挨个顺着指针跳过去,读出一个完整的 Python 对象再调用它的方法——这是逐元素的 Python 调用,不是向量化。
string[pyarrow] 把整列存成一块连续的 UTF-8 字节,操作交给编译好的 Arrow C++ 内核整块处理。
实测差距
拿真实的股票名称列复制到 22 万行:
s = names.astype("string[pyarrow]")
.str.contains("ST") |
.str.upper() |
内存 | |
|---|---|---|---|
object |
18.2 ms | 13.3 ms | 18.6 MB |
string[pyarrow] |
5.4 ms | 2.6 ms | 4.2 MB |
快 3.3 倍和 5.2 倍,内存省 77%。(耗时随机器会有出入,量级稳定。)
TIP: 🚀 什么时候值得转
df["name"] = df["name"].astype("string[pyarrow]")或者读数据时整表指定
dtype_backend="pyarrow"。
判断标准:这一列是不是又大又要反复做字符串操作。几万行的基础信息表无所谓,几百万行的文本列(公告、日志、逐笔备注)差别就很明显了。
顺带一提,string[python]这个后端不会变快——它底层还是 Python 字符串对象,只是多了统一的pd.NA缺失语义。要提速必须是pyarrow。
🎮 随堂快练
QUESTION: 第 08 讲说
ts_code列转category能把 10MB 降到 0.4MB。这一讲说转string[pyarrow]能省 77%。同一列该选哪个?
TIP: 👉 答案
看重复度。ts_code只有 300 个不同值却有 17 万行,重复度极高——category只存一次码表加一列整数码,省得多(27 倍)。
string[pyarrow]不去重,省的是每个 Python 字符串对象的开销,适合取值基本不重复的文本列(公司公告全文、用户备注)。
判断方法:s.nunique() / len(s)。真实数据上算一下:market列是 0.0007(4 个板块 5519 行),name列是 1.0000(每只股票名字都不同)。比值很小就用category,接近 1 就用string[pyarrow]。
🏋️ 训练营
QUESTION: 🟢 训练 1:给
s = pd.Series([" 600519.SH ", "000001.SZ", None, "*ST国华"]),写出:① 去掉首尾空格 ② 提取纯数字代码(没有的给 NaN) ③ 判断是否为 ST 股,缺失当作 False
TIP: 👉 参考s.str.strip() # ① s.str.strip().str.extract(r"^(\d{6})") # ② s.str.contains("ST", na=False) # ③② 用
extract而不是split(".")更稳——"*ST国华"里没有点,split会返回它本身而不是 NaN,extract匹配不上就给 NaN,意图更明确。
③na=False不能省,否则缺失行返回NaN,拿去做布尔索引会报错。
QUESTION: 🟡 训练 2:你有一列纯数字代码
["600519", "000001", "301234", "920001"],要补上交易所后缀。写出向量化的实现,并说明为什么不用apply。
TIP: 👉 参考sym = s.astype(str).str.zfill(6) out = np.select([sym.str[0] == "6", sym.str[0].isin(["0", "3"]), sym.str[0] == "9"], [sym + ".SH", sym + ".SZ", sym + ".BJ"], default=sym)不用
apply的理由(第 07 讲):这里每个分支都是整列运算,np.select全程向量化;apply会退回逐行 Python 调用,在几百万行的代码列上慢几百倍。
注意default=sym而不是default=np.nan——但更重要的是检查 default 命中了多少行:(out == sym).sum(),命中不为 0 说明有规则外的代码,要去查清楚是什么。
QUESTION: 🔴 训练 3:你要构造一个”剔除 ST、按市值分五档”的股票池。数据是
stock_basic(含name、ts_code)和某日的daily_basic(含ts_code、total_mv)。写出完整流程,并指出三处容易出错的地方。
TIP: 👉 参考pool = (db.merge(sb"ts_code", "name", on="ts_code", how="left") # ① .loc[lambda d: ~d["name"].str.contains("ST", na=False)] # ② .assign(mv_q=lambda d: pd.qcut(d["total_mv"], 5, # ③ labels=["最小","小","中","大","最大"])))三处易错:
① 合并键的格式——两张表的ts_code必须都带后缀或都不带,否则就是开场那个 0 行的结果。合并后立刻检查pool["name"].isna().sum(),不为 0 说明有没匹配上的。
②na=False不能省——how="left"合并后可能产生缺失的name,不写会报错。另外这里剔除的是contains("ST")而不是startswith("*ST"),两者差 107 只。
③ 分档必须在剔除之后做——顺序反了的话,被剔除的 ST 股参与了分位数计算,每档的边界就变了。这类”先筛还是先算”的顺序问题,是因子构造里最常见的错误来源。
🐛 常见坑
- ⚠️ 跨源合并前没统一代码格式:
"000001.SZ"和"000001"合不上,merge返回空表且不报错。合并后立刻检查结果行数。 - ⚠️ 行情表按单列判重:必须用
["ts_code", "trade_date"],只按代码判重会删掉整段历史。 - ⚠️
drop_duplicates(keep="last")前没排序:keep依据的是当前行顺序,不是时间顺序。 - ⚠️
map未覆盖的值静默变 NaN:映射表漏了类别不会报错,结果直接变缺失。用fillna兜底,或改用replace。 - ⚠️
.str.contains忘了na=False:缺失行返回NaN,做布尔索引时报错。 - ⚠️
.str.replace的regex参数:默认值不可靠,pattern 里有元字符时结果天差地别。永远显式写。 - ⚠️ 右偏数据用
cut等宽分箱:市值五等分会出现 5309 / 12 / 2 / 3 / 3 这种结果。因子分组用qcut。 - ⚠️
qcut遇重复边界报错:加duplicates="drop",但要意识到实际档数会少于你要的档数。 - ⚠️ 哑变量完全共线:110 个行业造 110 列,行和恒为 1。回归前
drop_first=True。 - ⚠️ 筛选和分箱的顺序:先剔除再分箱,否则被剔除的样本影响了分位数边界。
- ⚠️ 大文本列留在 object 上:几百万行反复做
.str操作,转string[pyarrow]快三倍、省七成内存。
✍️ 作业
- 读本地
stock_basic,用crosstab交叉统计代码首位数字和交易所后缀,自己反推出后缀规则。然后按这个规则从symbol重建ts_code,计算与原列的一致率。 - 统计这份数据里
contains("ST")和startswith("*ST")各命中多少只,解释两个数字为什么不同,以及做股票池过滤时该用哪个。 - 造一张只覆盖部分行业的映射表,用
map转换后统计缺失率;再分别用fillna(原列)、fillna("其它")、replace三种方式处理,比较三者结果的差异。 - 取某日的全市场
total_mv,分别用cut(5)和qcut(5)分档,打印每档数量。再打印total_mv.describe(),用均值和中位数的关系解释为什么两者差距这么大。 - 把一列股票名称复制到几十万行,分别在
object和string[pyarrow]下计时.str.contains,并对比memory_usage(deep=True)。再对ts_code列做同样的对比,加上category,说明三者各适合什么样的列。 - 思考题:开场那个 merge 返回 0 行的例子里,
merge什么都没说。如果两张表的代码格式只有一部分不一致(比如 90% 能对上、10% 不能),你会怎么发现?(提示:how="inner"之外还有别的取值;以及合并前后的行数应该满足什么关系。)
🔮 下讲预告:第 11 讲——Categorical 分类数据。这一讲
cut/qcut返回的那个类型,下一讲是主角。第 08 讲你已经见过它的效果:ts_code列从 10MB 降到 0.4MB。下一讲讲清它是怎么做到的——把重复的字符串换成一张码表加一列整数码,以及这个结构为什么还能让groupby和比较运算快好几倍。