第10讲 数据转换与字符串处理

📎 配套代码第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 的坑
  • cutqcut 分箱,并说清为什么因子分组几乎总是用 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: 一列股票代码是整数 1858600519(第 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 特定的几个值,出现在哪儿都换 -999NaN
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: 🔑 mapreplace 在”没查到”时行为相反
map 把没查到的变 NaNreplace 把没查到的原样保留。
想做”完整替换、剩下的丢弃”用 map;想做”部分替换、其余不动”用 replace。选错的后果是静默丢数据。
map 之前查一句:set(df["col"]) - set(mp) 看看有哪些值没被覆盖。

🎮 随堂快练

QUESTION: 你要把 110 个细分行业归并成 5 个大类,但只整理出了其中 80 个的对应关系。用 map 还是 replace
TIP: 👉 答案
都不理想,取决于你想怎么处理剩下的 30 个:

  • 想让它们显式变成”未分类”:map(mp).fillna("未分类")——这样它们在后续分组里是一个可见的类别,你能看到有多少。
  • 想让它们保持细分行业名:replace(mp)——但结果会混着大类和细分类,分组时会出现 35 个组,很容易被忽略。
    推荐第一种。归并这类操作,剩余项应该是显式可见的,而不是混在结果里。

四、🧰 分箱:cutqcut

把连续数值切成几档,是因子研究的基本动作——市值分五档看规模效应、按因子值分十档看单调性。

  • 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(含 namets_code)和某日的 daily_basic(含 ts_codetotal_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.replaceregex 参数:默认值不可靠,pattern 里有元字符时结果天差地别。永远显式写。
  • ⚠️ 右偏数据用 cut 等宽分箱:市值五等分会出现 5309 / 12 / 2 / 3 / 3 这种结果。因子分组用 qcut
  • ⚠️ qcut 遇重复边界报错:加 duplicates="drop",但要意识到实际档数会少于你要的档数。
  • ⚠️ 哑变量完全共线:110 个行业造 110 列,行和恒为 1。回归前 drop_first=True
  • ⚠️ 筛选和分箱的顺序:先剔除再分箱,否则被剔除的样本影响了分位数边界。
  • ⚠️ 大文本列留在 object 上:几百万行反复做 .str 操作,转 string[pyarrow] 快三倍、省七成内存。

✍️ 作业

  1. 读本地 stock_basic,用 crosstab 交叉统计代码首位数字和交易所后缀,自己反推出后缀规则。然后按这个规则从 symbol 重建 ts_code,计算与原列的一致率。
  2. 统计这份数据里 contains("ST")startswith("*ST") 各命中多少只,解释两个数字为什么不同,以及做股票池过滤时该用哪个。
  3. 造一张只覆盖部分行业的映射表,用 map 转换后统计缺失率;再分别用 fillna(原列)fillna("其它")replace 三种方式处理,比较三者结果的差异。
  4. 取某日的全市场 total_mv,分别用 cut(5)qcut(5) 分档,打印每档数量。再打印 total_mv.describe(),用均值和中位数的关系解释为什么两者差距这么大。
  5. 把一列股票名称复制到几十万行,分别在 objectstring[pyarrow] 下计时 .str.contains,并对比 memory_usage(deep=True)。再对 ts_code 列做同样的对比,加上 category,说明三者各适合什么样的列。
  6. 思考题:开场那个 merge 返回 0 行的例子里,merge 什么都没说。如果两张表的代码格式只有一部分不一致(比如 90% 能对上、10% 不能),你会怎么发现?(提示:how="inner" 之外还有别的取值;以及合并前后的行数应该满足什么关系。)

🔮 下讲预告:第 11 讲——Categorical 分类数据。这一讲 cut/qcut 返回的那个类型,下一讲是主角。第 08 讲你已经见过它的效果:ts_code 列从 10MB 降到 0.4MB。下一讲讲清它是怎么做到的——把重复的字符串换成一张码表加一列整数码,以及这个结构为什么还能让 groupby 和比较运算快好几倍。


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