Pandas 字符串处理:str 访问器与正则表达式
问卷的开放题、文献库导出的作者字段、仪器软件吐出的文件名,进到 pandas 里都是字符串列,而且几乎总带着不一致:大小写混用、前后空格、单位写在数值后面、全角半角字符并存。处理这类数据要解决三件事:找出符合条件的行、从字符串里抠出结构化信息、把写法统一。
pandas 把这些操作挂在 .str 访问器上。与 Python 内置的字符串方法不同,.str 后面的方法作用在整列上,返回的是一个新 Series,不需要写循环。正则部分直接用 Python re 模块的语法。
str 访问器:整列操作
Section titled “str 访问器:整列操作”构造一份字符串数据。下面用的是 R 内置数据集 mtcars 的 32 个车型名称,与 /r/tidyverse/stringr/ 的示例取自同一份数据,输出可以直接对照:
import pandas as pd
cars = pd.Series([ "Mazda RX4", "Mazda RX4 Wag", "Datsun 710", "Hornet 4 Drive", "Hornet Sportabout", "Valiant", "Duster 360", "Merc 240D", "Merc 230", "Merc 280", "Merc 280C", "Merc 450SE", "Merc 450SL", "Merc 450SLC", "Cadillac Fleetwood", "Lincoln Continental", "Chrysler Imperial", "Fiat 128", "Honda Civic", "Toyota Corolla", "Toyota Corona", "Dodge Challenger", "AMC Javelin", "Camaro Z28", "Pontiac Firebird", "Fiat X1-9", "Porsche 914-2", "Lotus Europa", "Ford Pantera L", "Ferrari Dino", "Maserati Bora", "Volvo 142E",])
print(cars.dtype)print(cars.str.upper().head(3).tolist())str['MAZDA RX4', 'MAZDA RX4 WAG', 'DATSUN 710']dtype 是 str。一列里如果混进了数字,整列会退化成 object,此时 .str 仍然能用,但元素类型不再统一,正则匹配会得到意料之外的结果。
.str 只在字符串列上有效。对数值列调用会直接报错,报错信息本身很有用:
import numpy as np
weights = pd.Series([1.5, 2.5])weights.str.replace(".", "-")AttributeError: Can only use .str accessor with string values, not floating. Did you mean: 'std'?修法是先转成字符串:weights.astype(str).str.replace(...)。这一步经常被跳过,然后在 .str 上撞墙。
检测:str.contains
Section titled “检测:str.contains”str.contains() 返回布尔 Series,可以直接当筛选条件用。^Merc 里的 ^ 表示字符串开头,所以只有以 Merc 开头的名字会被匹配:
print(cars.str.contains("^Merc").sum())print(cars[cars.str.contains("^F")].tolist())7['Fiat 128', 'Fiat X1-9', 'Ford Pantera L', 'Ferrari Dino']sum() 作用在布尔 Series 上就是数 True 的个数。第二个例子把布尔 Series 塞进方括号,取出的仍是 Series,.tolist() 只是为了打印得短一些。
默认区分大小写。忽略大小写用 case=False,不要去改数据本身的大小写:
print(cars.str.contains("merc", case=False).sum())print(cars.str.startswith("Merc").sum())print(cars.str.endswith("D").sum())771str.startswith() 和 str.endswith() 是纯字面量匹配,不解析正则,比 contains("^Merc") 少一层解释,也更难写错。只有确实需要正则时才用 contains()。
缺失值的行为要单独确认。 pandas 3 里 str.contains() 遇到缺失值默认返回 False,该行会被静默排除:
s = pd.Series(["Merc 230", None, "Fiat 128"])print(s.str.contains("^Merc").tolist())print(s.str.contains("^Merc", na=False).tolist())[True, False, False][True, False, False]两行结果一样,但含义不同。默认值是「当作不匹配」,而 na=False 是把它显式写出来。清洗数据时把 na= 显式写出来是更好的习惯:读代码的人不必去回忆默认规则,将来 pandas 改了默认行为也不会悄悄影响你的结果。
提取:str.extract
Section titled “提取:str.extract”str.extract() 按捕获组提取内容。只给一个组并且加 expand=False 时返回 Series,匹配不上填 NaN:
print(cars.str.extract(r"(\d+)", expand=False).head().tolist())['4', '4', '710', '4', nan]RX4 里的 4、710 里的 710 被抽出来,而 Hornet Sportabout 里没有数字,返回 NaN。\d 匹配一个数字,+ 表示一次或多次,所以 \d+ 抓的是连续的一整段数字。
需要一次提取多组信息时用命名分组,返回值自动变成 DataFrame,列名就是组名:
parts = cars.str.extract(r"^(?P<make>[A-Za-z]+)\s+(?P<rest>.+)$")print(parts.head(3))print(parts.dtypes.to_string()) make rest0 Mazda RX41 Mazda RX4 Wag2 Datsun 710make strrest str命名分组比按列位置取值可读得多,parts["make"] 不会因为正则里组的顺序调整而取错。
一个字符串里有多处匹配时,str.extract() 只返回第一处。全部取出用 str.extractall(),它返回多行索引的 DataFrame:
mixed = pd.Series(["1a2b3", "4c"])print(mixed.str.extractall(r"(\d)").to_string()) 0 match0 0 1 1 2 2 31 0 4索引变成了两层:原行号加 match 序号。多数情况下你需要的是 reset_index() 之后再做汇总。
替换:str.replace
Section titled “替换:str.replace”str.replace() 有两个默认值需要记住,它们和 str.contains() 不一致。
第一个是默认按字面量匹配,不做正则解释。判断一个字符串里有没有真正的小数点,两种写法差别很大:
dots = pd.Series(["a.b", "axb"])print(dots.str.replace(".", "-").tolist())print(dots.str.replace(".", "-", regex=True).tolist())['a-b', 'axb']['---', '---']第一行只把真正的点换掉,第二行把每个字符都换成了连字符——因为正则里的 . 匹配任意字符。要按正则匹配必须显式写 regex=True,这个默认值与 str.contains() 相反,contains 默认就是正则。
第二个是默认替换全部匹配,参数是 n=-1。只替换前 n 处用 n=:
d = pd.Series(["1 2 3 4"])print(d.str.replace(" ", "-").tolist())print(d.str.replace(" ", "-", n=2).tolist())['1-2-3-4']['1-2-3 4']str.replace() 与 R 的 str_replace() 语义相反:R 的 str_replace() 只换第一处,要换全部得写 str_replace_all();pandas 默认换全部,要换第一处得写 n=1。两种语言之间切换时这个差异最容易出错。
正则模式下可以用捕获组重排内容,替换串里用 \1、\2 引用:
names = pd.Series(["Sepal.Length", "Petal.Width"])print(names.str.replace(r"^(\w+)\.(\w+)$", r"\2 (\1)", regex=True).tolist())['Length (Sepal)', 'Width (Petal)']取出车名里的厂商部分,就是「把第一个空白及其后所有内容删掉」:
print(cars.str.replace(r"\s.*$", "", regex=True).head(6).tolist())['Mazda', 'Mazda', 'Datsun', 'Hornet', 'Hornet', 'Valiant']\s 匹配空白字符,.*$ 匹配其后到行尾的全部内容,替换成空串等于删除。
str.split() 返回的是装着列表的 Series,不能直接当一列用:
print(cars.str.split(" ").head(2).tolist())print(cars.str.split(" ", expand=True).head(3))[['Mazda', 'RX4'], ['Mazda', 'RX4', 'Wag']] 0 1 20 Mazda RX4 NaN1 Mazda RX4 Wag2 Datsun 710 NaNexpand=True 把列表摊成多列,列名是 0、1、2。元素个数不一致时会用 NaN 补齐,多出一批空列,所以更常用的写法是先拆分再取第 n 个元素:
print(cars.str.split(" ").str[0].head(4).tolist())print(cars.str.slice(0, 4).head(3).tolist())['Mazda', 'Mazda', 'Datsun', 'Hornet']['Mazd', 'Mazd', 'Dats'].str[0] 在拆分结果上再取第一个元素,链式写法读起来就是「按空格切开,取第一段」。str.slice() 按位置切片,等价于 str[0:4]。
按分隔符切分也可以走正则,把 pat 写成模式并加 regex=True:
print(pd.Series(["a1b22c"]).str.split(r"\d+", regex=True).tolist())[['a', 'b', 'c']]拼接用 str.cat(),补位用 str.zfill()。生成带前导零的样本编号是常见需求,Excel 里丢掉的前导零就靠它补回来:
sid = pd.Series([1, 2, 12])grp = pd.Series(["ctrl", "ctrl", "treat"])print(sid.astype(str).str.zfill(3).str.cat(grp, sep="-").tolist())['001-ctrl', '002-ctrl', '012-treat']str.zfill() 需要字符串列,先 astype(str)。另一个等价写法是 str.pad(3, side="left", fillchar="0")。str.join() 则用于把 Series 里每个列表元素拼成一个字符串,是 str.split() 的逆操作。
正则表达式速查
Section titled “正则表达式速查”下面这些覆盖了日常八成场景,Python 与 R 的语法基本一致,差别主要在转义层数:
| 写法 | 含义 |
|---|---|
\d / \D |
一个数字 / 一个非数字 |
\s / \S |
一个空白字符(空格、制表、换行)/ 非空白 |
\w / \W |
字母、数字或下划线 / 取反 |
[abc] / [^abc] |
字符集合中的任意一个 / 集合之外 |
. |
任意一个字符(要匹配真正的点得写 \.) |
^ / $ |
字符串开头 / 结尾 |
* / + / ? |
0 次以上 / 1 次以上 / 0 或 1 次 |
{2,4} |
重复 2 到 4 次 |
(...) |
捕获组,替换串里用 \1 引用 |
(?P<name>...) |
命名捕获组,成为 DataFrame 的列名 |
| `a | b` |
写正则字符串时用原始字符串 r"..."。Python 的普通字符串里 \d 会被当成未知转义序列,r"\d" 才能原样传给正则引擎。R 里则要写双反斜杠 "\\d"——两条语言的转义写法不同,但正则本身的语法是同一套。
完整示例:解析车型名称
Section titled “完整示例:解析车型名称”把检测、提取、排序接起来,从车型名称里拆出排量数字。为了与 R 版的输出逐行对照,这里把 mtcars 的油耗一并构造进来:
mtcars = pd.DataFrame({ "car": cars, "mpg": [21.0, 21.0, 22.8, 21.4, 18.7, 18.1, 14.3, 24.4, 22.8, 19.2, 17.8, 16.4, 17.3, 15.2, 10.4, 10.4, 14.7, 32.4, 30.4, 33.9, 21.5, 15.5, 15.2, 13.3, 19.2, 27.3, 26.0, 30.4, 15.8, 19.7, 15.0, 21.4],})
merc = mtcars[mtcars["car"].str.contains("^Merc")].copy()merc["engine"] = merc["car"].str.extract(r"(\d+)", expand=False)print(merc.sort_values("mpg", ascending=False).to_string(index=False)) car mpg engine Merc 240D 24.4 240 Merc 230 22.8 230 Merc 280 19.2 280 Merc 280C 17.8 280 Merc 450SL 17.3 450 Merc 450SE 16.4 450Merc 450SLC 15.2 450结果与 R 版完全一致。mtcars[...] 得到的是一份筛选结果,不写 .copy() 也能安全地加列:pandas 3 的 Copy-on-Write 始终开启,对筛选结果的修改不会回写到 mtcars。.copy() 留着是为了把「这是一份独立数据」的意图写明白。
engine 列的 dtype 是 str,提取出来的东西永远是字符串。想算平均排量必须先转成数值:
merc["engine"] = merc["engine"].astype(int)print(merc["engine"].mean())340.0忘了这一步会得到一个明确的报错,而不是静默算错:
cars.str.extract(r"(\d+)", expand=False).mean()TypeError: Cannot perform reduction 'mean' with string dtype这个报错比 R 的 mean() 返回 NA 加一条警告更好排查,但前提是你在提取之后立刻处理类型,而不是等到几行代码之后才发现。
. 匹配任意字符。 判断字符串里有没有句点,contains(".") 对任何非空字符串都返回 True。正确写法是 contains(r"\.") 或 contains(".", regex=False)。文献 DOI、化学式、文件扩展名里全是这类字符。
str.contains() 和 str.replace() 的正则默认值相反。 contains 默认 regex=True,replace 默认 regex=False。不要靠记忆,两种情况都把参数显式写出来。
缺失值不是所有方法都传播。 str.extract()、str.replace()、str.lower()、str.len() 遇到缺失值返回 NaN;str.contains() 在 pandas 3 里默认返回 False。str.len() 一列只要有缺失值,整列 dtype 会变成浮点:
s = pd.Series(["Merc 230", None, "Fiat 128"])print(s.str.len().tolist())print(s.str.len().fillna(0).astype(int).tolist())[8.0, nan, 8.0][8, 0, 8]要拿长度做比较或相加,先用 fillna() 决定缺失值算几,再 astype(int)。
全角字符看起来一样,匹配不上。 中文输入法下打出的空格是全角空格(U+3000),与半角空格在屏幕上几乎无法分辨,但正则里的 \s 和字面量 " " 都不匹配它:
w = pd.Series(["Merc 230", "Fiat 128"])print(w.str.contains("Merc 230").tolist())print(w.str.replace(" ", " ", regex=False).tolist())[False, False]['Merc 230', 'Fiat 128']从 Excel、问卷平台、中文 PDF 复制的文本经常带全角空格和全角标点。清洗时先做一次全角转半角,比在每个正则里补字符类更省事。
中文按字符计数。 str.len() 统计的是字符数,pd.Series(["数据分析", "R 语言"]).str.len() 返回 [4, 4],不是 UTF-8 字节数;str.slice() 也按字符取位置,处理中文不会切出乱码。但 str.upper() 对汉字没有任何效果,str.count() 数拉丁字母时才准确。
字符串处理通常是数据整理的第一步。提取出来的内容要参与计算,就得先做类型转换,这一步在 /python/pandas/cleaning/ 里连同缺失值处理一起讲;如果你也用 R,对应的写法在 /r/tidyverse/stringr/,同一份 mtcars 车型数据在两边的输出可以直接对照。