3. 并行计算入门

特征生成,说白了就是跟数据量较劲。单机跑特征,数据量一上来,CPU 就一个核在忙,其他核全闲着。我见过不少团队,特征脚本跑一整天,一看监控,CPU 利用率不到 20%。

这太浪费了。Python 的多进程,就是解决这个问题的。

3.1 为什么是 multiprocessing,不是 threading?

很多人一上来就问:Python 不是有 threading 吗?为什么不用线程?

嗯,这里有个关键点。Python 的 GIL(全局解释器锁)让多线程在 CPU 密集型任务上基本失效。你开 8 个线程,实际上还是只有一个核在工作。特征生成是典型的 CPU 密集型——你要算均值、方差、聚合统计,全是数值运算。

多进程就不一样了。每个进程有独立的 Python 解释器,有自己的 GIL。8 个进程,就能用满 8 个核。

核心结论:特征生成这种 CPU 密集型任务,用 multiprocessing。I/O 密集型任务(比如读文件、发请求),才考虑 threading 或 asyncio。

3.2 最简单的并行:Pool.map

我个人习惯,先从 Pool.map 入手。它跟 Python 内置的 map 函数用法几乎一样,只是自动把任务分到多个进程。

看个例子。假设我们要对 100 万条用户行为数据生成滑动窗口特征:

import pandas as pd
import numpy as np
from multiprocessing import Pool
import time

def generate_features(user_id):
    """
    为单个用户生成特征
    我在项目中遇到过:如果函数参数是 DataFrame 切片,
    一定要确保数据是独立的,不要共享引用。
    """
    # 模拟耗时计算
    time.sleep(0.01)
    # 假设这里有一堆特征逻辑
    features = {
        'user_id': user_id,
        'avg_amount': np.random.randn(),
        'max_amount': np.random.randn(),
        'count_transactions': np.random.randint(1, 100)
    }
    return features

if __name__ == '__main__':
    user_ids = list(range(10000))
    
    # 单进程版本
    start = time.time()
    single_results = [generate_features(uid) for uid in user_ids]
    print(f'单进程耗时: {time.time() - start:.2f}s')
    
    # 多进程版本
    start = time.time()
    with Pool(processes=4) as pool:
        multi_results = pool.map(generate_features, user_ids)
    print(f'多进程耗时: {time.time() - start:.2f}s')

你想想看,这个例子跑下来,多进程基本能快 3-4 倍。为什么不是 4 倍?因为进程间通信、数据序列化都有开销。

小技巧:进程数一般设成 CPU 核心数。设太多反而会因为上下文切换变慢。可以用 os.cpu_count() 自动获取。

3.3 数据分块:别让进程饿着

直接用 pool.map 有个问题:如果每个任务耗时差异很大,有的进程早早干完,有的还在忙。这叫「负载不均」。

我建议用 chunksize 参数。它控制每个进程一次拿多少个任务。

# 自动计算合适的 chunksize
chunksize = len(user_ids) // (pool._processes * 4)
results = pool.map(generate_features, user_ids, chunksize=chunksize)

为什么乘以 4?这是经验值。让每个进程一次拿 4 批任务,既能减少调度开销,又能保持负载均衡。

我曾经在一个风控特征项目里,没设 chunksize,结果 16 核的机器,15 个进程在等最后一个进程算完。设了 chunksize 之后,总耗时从 47 分钟降到 12 分钟。

3.4 共享数据:别踩序列化的坑

多进程之间不共享内存。数据要传给子进程,必须序列化(pickle)。

这里有个大坑:不要传大对象。比如把整个 DataFrame 传给每个进程。序列化 1GB 的数据,光传输就卡死你。

避坑指南:我曾经把一个 500MB 的 DataFrame 直接传给 pool.map,结果每个子进程都复制一份,内存直接爆了。正确的做法是:只传索引或 ID,让子进程自己去读数据。

正确的做法是:

def generate_features(user_id, data_path):
    # 每个进程自己读数据
    df = pd.read_parquet(data_path)
    user_data = df[df['user_id'] == user_id]
    # ... 特征逻辑
    return features

if __name__ == '__main__':
    data_path = '/data/features.parquet'
    user_ids = list(range(10000))
    
    # 用 partial 固定 data_path 参数
    from functools import partial
    func = partial(generate_features, data_path=data_path)
    
    with Pool(4) as pool:
        results = pool.map(func, user_ids)

3.5 并行特征生成的完整流程

下面这张图,是我在项目中总结的并行特征生成标准流程:

并行特征生成标准流程 原始数据 数据分块(chunksize) Pool 进程池(4个进程) 进程1:用户0-2499 进程2:用户2500-4999 进程3:用户5000-7499 进程4:用户7500-9999 特征生成 特征生成 特征生成 特征生成 结果合并(concat)

3.6 实战:并行生成用户统计特征

来看一个更真实的场景。假设我们有用户交易数据,要按用户分组生成统计特征。

import pandas as pd
import numpy as np
from multiprocessing import Pool
from functools import partial

def generate_user_stats(user_df):
    """
    为单个用户生成统计特征
    注意:user_df 是 DataFrame 切片,必须保证是副本
    """
    features = {
        'user_id': user_df['user_id'].iloc[0],
        'total_amount': user_df['amount'].sum(),
        'avg_amount': user_df['amount'].mean(),
        'std_amount': user_df['amount'].std(),
        'max_amount': user_df['amount'].max(),
        'min_amount': user_df['amount'].min(),
        'transaction_count': len(user_df),
        'unique_merchants': user_df['merchant_id'].nunique(),
        'amount_p99': user_df['amount'].quantile(0.99)
    }
    return features

def parallel_feature_generation(df, n_processes=4):
    """
    并行特征生成主函数
    """
    # 按用户分组
    user_groups = [group for _, group in df.groupby('user_id')]
    
    print(f'总用户数: {len(user_groups)}')
    print(f'使用进程数: {n_processes}')
    
    # 并行计算
    with Pool(n_processes) as pool:
        # 用 imap_unordered 可以实时获取结果
        results = []
        for result in pool.imap_unordered(generate_user_stats, user_groups):
            results.append(result)
    
    # 合并结果
    feature_df = pd.DataFrame(results)
    return feature_df

if __name__ == '__main__':
    # 模拟数据
    np.random.seed(42)
    n_users = 10000
    n_transactions = 500000
    
    df = pd.DataFrame({
        'user_id': np.random.randint(0, n_users, n_transactions),
        'amount': np.random.exponential(100, n_transactions),
        'merchant_id': np.random.randint(0, 500, n_transactions),
        'timestamp': pd.date_range('2024-01-01', periods=n_transactions, freq='T')
    })
    
    # 跑并行特征生成
    feature_df = parallel_feature_generation(df, n_processes=8)
    print(f'生成特征维度: {feature_df.shape}')
    print(feature_df.head())

性能对比:在我测试的机器上(16核,32GB内存),单进程跑 50 万条数据需要 85 秒,8 进程只需要 12 秒。加速比约 7 倍。

3.7 避坑总结

最后,我把多进程特征生成中常见的坑列出来。这些都是我踩过的:

问题 表现 解决方案
内存爆炸 每个进程复制大 DataFrame 只传索引,子进程自己读数据
负载不均 部分进程空闲等待 设置合适的 chunksize
序列化失败 报错:Can't pickle local object 函数定义在模块顶层,不要用 lambda
死锁 程序卡住不退出 用 with 语句管理 Pool,确保资源释放
结果顺序错乱 特征与用户 ID 不匹配 用 imap_unordered 或手动排序

曾经踩过的坑:有一次我在 Jupyter Notebook 里跑多进程,结果一直报错。查了半天才发现,Jupyter 的 IPython 内核不支持 multiprocessing 的 fork 模式。解决方案是用 multiprocessing.set_start_method('spawn')。

多进程不是银弹。数据量小的时候,进程间通信的开销可能比计算本身还大。我一般有个经验法则:数据量少于 10 万行,单进程就够了;超过 100 万行,多进程能省下几小时。