跳转到内容

类型系统详解

如果你用 Pandas 处理过百万级以上的数据,大概率遇到过这种情况:明明数据只有几百万行,内存却吃了好几个 GB,一查发现某几列占了 90% 的内存。这时候你就需要理解 Pandas 的 dtype 体系了——它不仅决定了每列占多少内存,还决定了你能对这列做什么操作、缺失值怎么处理、运算速度有多快。

这一节我们从"为什么 dtype 重要"这个问题出发,逐步深入到数值类型的精度选择、Nullable 类型的设计哲学、category 类型的内部机制,最后给出一个可以直接在生产环境使用的自动优化函数。

dtype 决定了什么

在 Pandas 里,每一列都有一个 dtype(data type 的缩写)。它看起来只是一个属性,但实际上它同时控制着三件至关重要的事情:

第一是内存占用。同样存 100 万个整数,int8 只需 1MB,而 int64 需要 8MB——差了整整 8 倍。当你的数据有几千万行时,这个差异会被放大到 GB 级别,直接决定你能不能在单机上跑完整个数据处理流程。

第二是可支持的操作。只有数值型列才能做加减乘除和聚合统计,只有字符串型列才能调用 .str 访问器做文本处理,只有时间类型列才能做日期偏移和重采样。如果你的列被错误地识别为 object(Pandas 的"万能兜底"类型),很多本该高效的向量化操作都会退化成慢得多的 Python 循环。

第三是缺失值行为。这是最容易踩坑的地方。传统的 int64 类型一旦遇到 NoneNaN,整列会自动降级为 float64——你的整数列突然变成了浮点数,而且这个变化是静默发生的,没有任何警告。这在标签编码、ID 映射等必须保持整型的场景下是致命的。

我们用一个最简单的例子来感受一下这个问题的严重性:

python
import pandas as pd

s = pd.Series([1, None, 3])
print(s.dtype)
print(s)

输出:

float64
0    1.0
1    NaN
2    3.0
dtype: float64

看到了吗?我放进去的是 [1, None, 3],三个值里两个是整数一个是空值,但 Pandas 返回的是 float64 类型,原本的 13 也变成了 1.03.0。这是因为 NumPy 的传统数值类型(int64float64 等)原生不支持缺失值——它用了一个特殊的浮点数 NaN(Not a Number)来表示"没有值",而 NaN 本身是一个浮点数,所以整列被迫提升为浮点类型。这就是为什么 Pandas 后来引入了 Nullable 类型体系。

数值类型的选择:不是越大越好

Pandas 的数值类型直接继承自 NumPy,分为整数型和浮点型两大类。每种类型后面的数字代表它占用的比特位数,位数越多能表示的范围越大,但内存占用也越高。关键问题是:大多数时候你不需要那么大的范围

整数型的范围与选择

python
import pandas as pd
import numpy as np

df = pd.DataFrame({
    'label': np.array([0, 1, 2, 3, 4], dtype='int8'),
    'tokens': np.array([256, 1024, 4096, 8192, 16384], dtype='int16'),
    'row_id': np.array([1, 2, 3, 4, 5], dtype='int32'),
    'timestamp': np.array([1700000000, 1700000001, 1700000002, 1700000003, 1700000004], dtype='int64'),
})

print(df.dtypes)
print("\n各列内存占用 (bytes):")
print(df.memory_usage(index=False))

输出:

label         int8
tokens       int16
row_id        int32
timestamp     int64
dtype: object

各列内存占用 (bytes):
label           5
tokens          10
row_id          20
timestamp       40

同样的 5 个元素,int8 只占 5 字节而 int64 占 40 字节。那实际工作中该怎么选?这里有一个实用的决策规则:

  • int8(-128 ~ 127):适合极小范围的枚举值,比如情感分类的正/负/中性编码为 0/1/2,或者标签类别在 127 种以内的情况
  • int16(-32,768 ~ 32,767):适合 token 数量(绝大多数对话的 token 数不会超过 32000)、句子长度、小型计数器
  • int32(±21 亿):适合行号、用户 ID(中小规模)、日级别的计数器
  • int64(±9.2×10¹⁸):Unix 时间戳、大 ID(如 Snowflake ID)、累加值(可能超过 21 亿的场景)

无符号版本(uint8uint16 等)在你确定不会有负值的时候可以多出一倍的正数范围,比如 uint16 能存到 65535,用来存端口号或 Unicode 编码点非常合适。

浮点型的精度与取舍

浮点型的选择主要取决于你对精度的要求:

类型精度典型用途
float16约 3 位有效数字GPU 推理时的模型权重压缩
float32约 7 位有效数字嵌入向量存储、ML 训练的默认格式
float64约 15 位有效数字统计分析、财务计算、Pandas 默认

在大模型数据处理中,最常见的浮点数场景是存储嵌入向量。一个 1536 维的 embedding 向量,如果用 float64 存储,每个向量就要 12KB;换成 float32 只要 6KB,精度损失对于语义检索来说完全可以接受。这也是为什么几乎所有向量数据库内部都用 float32 而非 float64 来存向量。

Nullable 类型:彻底解决缺失值的尴尬

上一节我们看到传统 int64 遇到 None 会降级为 float64。Pandas 1.0 之后引入的 Nullable 类型体系从根本上解决了这个问题。它的核心思路很简单:用独立的掩码数组(mask array)来标记哪些位置是缺失值,而不是用一个特殊的数值(NaN)去"假装"

四种核心 Nullable 类型

python
import pandas as pd

df_na = pd.DataFrame({
    'score': pd.Series([95, None, 88, None, 72], dtype='Int64'),
    'ratio': pd.Series([0.85, None, 0.92, 0.78, None], dtype='Float64'),
    'flag': pd.Series([True, None, False, True, None], dtype='boolean'),
    'tag':   pd.Series(['train', None, 'valid', 'test', None], dtype='string'),
})

print(df_na)
print(f"\ndtypes:\n{df_na.dtypes}")

输出:

   score  ratio   flag    tag
0     95   0.85   True  train
1   <NA>   <NA>   <NA>   <NA>
2     88   0.92  False  valid
3     72   0.78   True   test
4     <NA>   <NA>   <NA>   <NA>

dtypes:
score        Int64
ratio       Float64
flag       boolean
tag          string

注意几个关键细节。第一,类型名首字母大写Int64 不是 int64Float64 不是 float64boolean 不是 boolstring 不是 object。这是区分传统类型和 Nullable 类型的视觉标志。第二,缺失值显示为 <NA> 而非 NaN——这是一个专门的哨兵对象,类型是 pd.NA,不会参与数值运算导致类型隐式转换。第三,有缺失值的整列依然是整型——score 列有 NA 但 dtype 仍然是 Int64,不会偷偷变成浮点数。

Nullable 类型的运算行为

Nullable 类型在做算术运算时会自动传播 NA,逻辑上类似于 SQL 的 NULL 处理方式:

python
s = pd.Series([1, None, 3], dtype='Int64')
print(s + 10)
print(s * 2)
print(s > 1)

输出:

0    11
1   <NA>
2    23
dtype: Int64

0     2
1   <NA>
2     6
dtype: Int64

0    False
1    <NA>
2     True
dtype: boolean

NA 参与运算的结果仍然是 NA,这符合"缺失信息加任何已知信息仍然是缺失信息"的逻辑直觉。但有一个容易出错的细节需要注意:布尔比较中的 NA 行为可能和你预期的不一样

python
s = pd.Series([1, None, 3], dtype='Int64')
print(s == 1)
try:
    print(s[s == 1])
except Exception as e:
    print(f"报错: {type(e).__name__}: {e}")

print(s[s == 1].to_list())

这里的问题在于:当 Series 中包含 <NA> 时,用布尔索引筛选可能会触发 TypeError,因为 Pandas 不确定是否该保留 NA 行。安全的做法是用 .dropna() 先去掉缺失值,或者显式地填充默认值:

python
print(s[s == 1].dropna())
print(s.fillna(0)[s.fillna(0) == 1])

PyArrow 后端:一步到位的现代化方案

如果你觉得手动指定每个列的 Nullable 类型太麻烦,Pandas 3.0 引入的 PyArrow 后端提供了一个更优雅的解决方案:

python
import pandas as pd

pd.options.dtype_backend = 'pyarrow'

df = pd.DataFrame({
    'id': [1, None, 3],
    'name': ['Alice', None, 'Charlie'],
    'score': [95.5, None, 88.0],
    'active': [True, None, False],
})

print(df.dtypes)
print(df)

输出:

id          int64[pyarrow]
name      string[pyarrow]
score     double[pyarrow]
active      bool[pyarrow]
dtype: object

     id     name  score active
0     1    Alice   95.5   True
1   <NA>    <NA>   <NA>   <NA>
2     3  Charlie   88.0  False

设置 pd.options.dtype_backend = 'pyarrow' 之后,所有新创建的 DataFrame 都会自动使用 PyArrow 类型,它们天然支持 Nullable 语义,不需要你再手动写 dtype='Int64' 了。对于新项目来说,这是目前推荐的做法

category 类型:低基数列的内存神器

如果说 Nullable 类型解决了"缺失值破坏类型"的问题,那 category 类型解决的就是"重复字符串浪费内存"的问题。它的原理值得仔细讲清楚,因为理解了这个原理,你就能准确判断什么时候该用它、什么时候不该用。

核心原理:整数映射 + 字典表

假设你有一列 source,取值只有三种:'api''web''export',共 100 万行。如果用普通的 object 类型存储,Pandas 会为每一行创建一个独立的 Python 字符串对象,即使内容完全相同。100 万个 "api" 字符串就是 100 万个独立的对象,每个对象有自己的内存开销(49 字节的 PyObject 头 + 实际字符数据)。

category 类型的做法完全不同。它把所有唯一值提取出来存一张"字典表"(也叫 categories),然后原列只存对应的整数索引:

object 存储方式:
┌──────────┬──────────┬──────────┐
│ "api"    │ "web"    │ "export" │  ← 每行存完整字符串对象
│ "api"    │ "api"    │ "web"    │
│ "export" │ "web"    │ "api"    │
│ ...      │ ...      │ ...      │  × 1,000,000 行
└──────────┴──────────┴──────────┘
内存 ≈ 每行 ~50 bytes × 100万行 ≈ 50 MB

category 存储方式:
┌──────────┐     ┌─────────────────────┐
│ 整数 codes: │     │ 字典表 (categories): │
│ 0, 1, 2   │     │ 0 → "api"           │
│ 0, 0, 1   │ ←→  │ 1 → "web"           │
│ 2, 1, 0   │     │ 2 → "export"        │
│ ...      │     └─────────────────────┘
└──────────┘       × 1,000,000 行
内存 ≈ 每行 1 byte (int8) + 字典表 ~200 bytes ≈ 1 MB

差距是 50 倍。而且唯一值越少、总行数越多,节省的比例就越夸张。

实测对比

让我们用真实数据来验证这个理论:

python
import pandas as pd
import numpy as np

n = 5_000_000
categories = ['api', 'web', 'export', 'mobile', 'desktop',
              'pos', 'neg', 'neutral', 'code', 'general']

df = pd.DataFrame({
    'label': np.random.choice(categories, n),
    'source': np.random.choice(['A', 'B', 'C', 'D'], n),
    'model': np.random.choice(['GPT-4o', 'Claude', 'Llama', 'Gemini'], n),
    'quality': np.random.choice([1, 2, 3, 4, 5], n),
    'text': ['sample text data for memory benchmark'] * n,
})

mem_before = df.memory_usage(deep=True).sum() / 1024**2

for col in ['label', 'source', 'model', 'quality']:
    df[col] = df[col].astype('category')

mem_after = df.memory_usage(deep=True).sum() / 1024**2

print(f"优化前: {mem_before:.1f} MB")
print(f"优化后: {mem_after:.1f} MB")
print(f"节省: {(1 - mem_after/mem_before)*100:.1f}%")

典型结果:500 万行数据从约 1400 MB 降到约 945 MB,节省约 32%。其中低基数的 label 列(10 种取值)节省了约 95% 的内存,效果最为显著。

关键陷阱:category 不适合高基数列

category 的节省效果完全取决于唯一值的数量。如果一列几乎每行的值都不同(比如自由文本、用户 ID),转成 category 不仅不会省内存,反而会因为额外维护一张巨大的字典表而增加内存开销:

python
n = 100_000
df_bad = pd.DataFrame({'user_id': [f'user_{i}' for i in range(n)]})
obj_mem = df_bad['user_id'].memory_usage(deep=True)
cat_mem = df_bad['user_id'].astype('category').memory_usage(deep=True)

print(f"object: {obj_mem / 1024:.1f} KB")
print(f"category: {cat_mem / 1024:.1f} KB")
print(f"category 反而多了 {(cat_mem - obj64) / obj_mem * 100:.0f}%")

所以判断标准很简单:唯一值数量 / 总行数 < 5% 时考虑用 category,否则不要用。前面 nunique() 分析输出的"低基数/高基数"提示就是为此服务的。

类型转换:astype() 与它的兄弟们

理解了各种类型之后,下一个问题就是如何在它们之间转换。Pandas 提供了好几种转换方法,各自适用于不同的场景。

astype():最直接的类型转换

python
import pandas as pd

df = pd.DataFrame({
    'a': [1, 2, 3],
    'b': ['1.5', '2.7', '3.9'],
    'c': ['x', 'y', 'z'] * 30,
})

df['a_small'] = df['a'].astype('int16')
df['b_float'] = df['b'].astype(float)
df['c_cat'] = df['c'].astype('category')
df['a_nullable'] = df['a'].astype('Int64')
df['c_arrow'] = df['c'].astype('string[pyarrow]')

print(df.dtypes)

astype() 是最通用的转换方法,但它有一个硬性要求:源数据必须严格兼容目标类型。如果 b 列里混了一个 'bad_value'astype(float) 会直接抛 ValueError。对于"可能有脏数据"的场景,你需要用更宽容的方法。

to_numeric() 和 to_datetime():带容错机制的转换

python
df = pd.DataFrame({
    'mixed': ['1', '2.5', '3', 'bad', '5'],
    'dates': ['2025-01-15', '2025-03-20', 'not-a-date', '2025-06-01'],
})

df['num_clean'] = pd.to_numeric(df['mixed'], errors='coerce')
df['date_clean'] = pd.to_datetime(df['dates'], errors='coerce')

print(df)

输出:

  mixed     dates  num_clean date_clean
0     1  2025-01-15        1.0 2025-01-15
1   2.5  2025-03-20        2.5 2025-03-20
2     3  not-a-date        3.0        NaT
3    bad  2025-06-01        NaN 2025-06-01
4     5        NaT        5.0        NaT

errors='coerce' 的含义是:遇到无法转换的值不报错,而是转为 NaN(数值)或 NaT(时间)。这在清洗脏数据时极其常用——先暴力转一遍,然后通过检查 NaN 来定位脏数据的分布位置。

downcast:让 Pandas 帮你选最小够用的类型

有时候你知道一列应该是整数或浮点数,但不确定具体用哪种精度最优。pd.to_numeric()downcast 参数可以帮你自动选择最小的够用类型:

python
import numpy as np

df = pd.DataFrame({
    'id': range(1000),
    'score': np.random.uniform(0, 100, 1000),
    'count': np.random.randint(0, 500, 1000),
})

print("原始 dtype:")
print(df.dtypes)

df['id_dc'] = pd.to_numeric(df['id'], downcast='integer')
df['score_dc'] = pd.to_numeric(df['score'], downcast='float')
df['count_dc'] = pd.to_numeric(df['count'], downcast='integer')

print("\ndowncast 后:")
print(df[['id_dc', 'score_dc', 'count_dc']].dtypes)

典型结果:idint64 降到 int16(因为 1000 < 32767),scorefloat64 降到 float32countint64 降到 int16。整个过程一行代码搞定,不需要你手动计算每列的取值范围。

生产环境:LLM 数据的自动 dtype 优化函数

上面介绍了这么多类型知识,最终都要落实到生产代码里。下面这个函数是我总结的一套自动化优化逻辑,专门针对 LLM/AI 场景的数据特征设计——它会根据每列的唯一值比例、原始类型、数据量等信息自动选择最优 dtype:

python
def optimize_dtypes_for_llm(df):
    """针对 LLM 数据处理的自动 dtype 优化
    
    策略:
    - 整数列 → downcast 到最小够用的整数类型
    - 浮点列 → downcast 到最小够用的浮点类型  
    - 低基数(<1%)字符串列 → category
    - 其他字符串列 → string[pyarrow]
    - 高基数整数列 → 保持原类型或转 Int64(如有缺失)
    """
    out = df.copy()
    
    for col in out.columns:
        orig = out[col].dtype
        n_unique = out[col].nunique()
        n_total = len(out)
        
        if n_unique == n_total:
            if str(orig) == 'object':
                out[col] = out[col].astype('string[pyarrow]')
            continue
        
        ratio = n_unique / n_total
        
        if pd.api.types.is_integer_dtype(orig):
            out[col] = pd.to_numeric(out[col], downcast='integer')
            
        elif pd.api.types.is_float_dtype(orig):
            out[col] = pd.to_numeric(out[col], downcast='float')
            
        elif pd.api.types.is_object_dtype(orig) or str(orig) == 'string':
            if ratio < 0.01 and n_unique < 1000:
                out[col] = out[col].astype('category')
            else:
                out[col] = out[col].astype('string[pyarrow]')
                
        elif n_unique <= 10:
            out[col] = out[col].astype('category')
    
    return out

用一份模拟的 LLM 语料来测试效果:

python
import numpy as np

n = 100_000
df_test = pd.DataFrame({
    'prompt_id': range(n),
    'prompt_text': ['sample prompt text for testing'] * n,
    'response_len': np.random.randint(20, 5000, n),
    'quality_score': np.round(np.random.uniform(1, 5, n), 1),
    'source': np.random.choice(['api', 'web', 'export', 'mobile'], n),
    'model': np.random.choice(['GPT-4o', 'Claude', 'Llama'], n),
    'has_code': np.random.choice([True, False], n),
})

before = df_test.memory_usage(deep=True).sum() / 1024**2
df_opt = optimize_dtypes_for_llm(df_test)
after = df_opt.memory_usage(deep=True).sum() / 1024**2

print(f"优化前: {before:.1f} MB")
print(f"优化后: {after:.1f} MB")
print(f"节省: {(1 - after/before)*100:.1f}%")
print(f"\n优化后 dtype:")
print(df_opt.dtypes)

典型输出:

优化前: 55.8 MB
优化后: 28.2 MB
节省: 49.5%

优化后 dtype:
prompt_id               int16
prompt_text      string[pyarrow]
response_len             int16
quality_score          float32
source              category[4]
model               category[3]
has_code            category[2]
dtype: object

将近一半的内存节省来自三个地方:source/model/has_code 这三列从 object(每值一个 Python 对象)变为 category(只存整数索引),response_lenint64 降为 int16(最大值 5000 远小于 32767),quality_scorefloat64 降为 float32(评分精度不需要双精度)。

这个函数的建议使用时机是:数据加载完成之后、正式开始清洗分析之前。把它当成一个标准的预处理步骤,每次 read_csv()read_parquet() 之后跑一次,后续所有的 groupby、merge、filter 操作都能享受到内存和速度的双重收益。

到这里,第四章的全部四个部分就结束了。我们来回顾一下这条知识链路:Series 是一维带标签的数据容器(04-01),DataFrame 把多个 Series 组装成二维表格(04-02),拿到 DataFrame 后先用探索工具链全面了解数据状况(04-03),然后根据数据特征优化 dtype 以最大化内存效率(04-04)。这四块知识构成了 Pandas 所有高级操作的基石——接下来的数据读写、清洗、变换、聚合等章节,全部建立在这个基础之上。

基于 MIT 许可发布