[{"content":"写 Python 写了几年，你或许曾遇到过这样的困惑：明明给数据处理加了多线程，CPU 占用率飙上去了，速度却几乎没变。这不是你的代码有 bug，而是 GIL 在起作用。\n本文从原理出发，用实验数据说话，帮你彻底搞清楚 GIL 的边界——以及在机器学习、深度学习、量化金融场景下如何系统性地绕开它。\n实验环境：Apple M3 Pro，Python 3.11.13，numpy 2.2.3，pandas 2.3.3，torch 2.11.0\n实验代码：https://github.com/Coldeye2020/gil_experiments\n一、GIL 是什么，为什么存在？ 一句话定义：GIL（Global Interpreter Lock，全局解释器锁）是 CPython 中的一把互斥锁，保证同一时刻只有一个线程在执行 Python 字节码。\n从引用计数说起 CPython 用引用计数（reference counting）管理内存。每个 Python 对象都维护一个 ob_refcnt 字段，记录当前有多少个引用指向它；当计数归零时，对象被立即释放。\n这个机制简洁高效，但存在一个问题：如果多个线程同时对同一个对象的引用计数做 +1 或 -1，就会产生竞态条件（race condition）——两个线程同时读到旧值 n，各自写回 n+1，结果只加了一次而不是两次。这会导致内存泄漏，甚至在错误时机释放仍在使用的对象，引发崩溃。\n理论上可以给每个对象的引用计数加一把细粒度锁，但这意味着几乎每次对象访问都要加锁解锁，开销极大。CPython 的设计者 Guido van Rossum 在 1990 年代初选择了一个更简单的方案：加一把大锁，锁住整个解释器。这把锁就是 GIL。\n用代码验证引用计数 import sys a = [] print(sys.getrefcount(a)) # 输出 2：a 本身持有 1 个引用，getrefcount 调用时临时持有 1 个 b = a print(sys.getrefcount(a)) # 输出 3：b 也引用了同一个列表 del b print(sys.getrefcount(a)) # 输出 2：b 被删除，引用数减 1 sys.getrefcount() 让我们直接观察引用计数的变化。正是这个机制的存在，让 GIL 成为 CPython 的\u0026quot;必要之恶\u0026quot;。\n注：PyPy、Jython、GraalPy 等其他 Python 实现不使用引用计数，因此没有 GIL。本文所讨论的 GIL 均指 CPython 实现。\n二、用实验证明 GIL 真的在拖后腿 光说不练没说服力。我们用两个实验来直接量化 GIL 的影响。\n实验1：CPU 密集型任务——多线程 vs 单线程 任务：用纯 Python 循环暴力求 [2, 50000) 内的质数个数，重复 4 次（模拟 4 个独立任务）。\nimport threading import time def count_primes(n: int) -\u0026gt; int: \u0026#34;\u0026#34;\u0026#34;暴力判断 [2, n) 内有多少个质数，纯 Python 循环，受 GIL 严格限制。\u0026#34;\u0026#34;\u0026#34; count = 0 for num in range(2, n): is_prime = True for divisor in range(2, int(num**0.5) + 1): if num % divisor == 0: is_prime = False break if is_prime: count += 1 return count N = 50_000 REPEAT = 4 # 单线程：顺序执行 4 次 t0 = time.perf_counter() for _ in range(REPEAT): count_primes(N) single_time = time.perf_counter() - t0 # 多线程：4 个线程同时执行 threads = [threading.Thread(target=count_primes, args=(N,)) for _ in range(REPEAT)] t0 = time.perf_counter() for t in threads: t.start() for t in threads: t.join() multi_time = time.perf_counter() - t0 print(f\u0026#34;单线程耗时：{single_time:.2f}s\u0026#34;) print(f\u0026#34;多线程耗时：{multi_time:.2f}s（4 个线程）\u0026#34;) print(f\u0026#34;加速比：{single_time / multi_time:.2f}x（理想应为 4x）\u0026#34;) 实验结果：\n耗时 加速比 单线程（4次串行） 0.09s 1x（基准） 多线程（4线程并发） 0.08s 1.01x 实验结论：4 个线程同时计算质数，耗时与单线程几乎相同，加速比仅 1.01x 而非理想的 4x。GIL 每次只允许一个线程执行字节码，4 个线程实质上是串行轮流执行，额外付出了线程切换开销。\n实验2：IO 密集型任务——多线程 vs 单线程 任务：向 httpbin.org/delay/1 发送 HTTP 请求（服务端固定等待 1 秒后返回），重复 4 次。\nimport urllib.request import threading import time def fetch_url(url: str): \u0026#34;\u0026#34;\u0026#34;发起一次 HTTP GET 请求，等待返回。IO 等待期间 GIL 会被释放。\u0026#34;\u0026#34;\u0026#34; with urllib.request.urlopen(url, timeout=10) as resp: resp.read() URL = \u0026#34;https://httpbin.org/delay/1\u0026#34; REPEAT = 4 # 单线程：顺序发 4 次请求，约 4 秒 t0 = time.perf_counter() for _ in range(REPEAT): fetch_url(URL) single_time = time.perf_counter() - t0 # 多线程：4 个线程并发发请求，约 1 秒 threads = [threading.Thread(target=fetch_url, args=(URL,)) for _ in range(REPEAT)] t0 = time.perf_counter() for t in threads: t.start() for t in threads: t.join() multi_time = time.perf_counter() - t0 print(f\u0026#34;单线程耗时：{single_time:.2f}s\u0026#34;) print(f\u0026#34;多线程耗时：{multi_time:.2f}s（4 个线程）\u0026#34;) print(f\u0026#34;加速比：{single_time / multi_time:.2f}x\u0026#34;) 实验结果：\n耗时 加速比 单线程（4次串行） 11.76s 1x（基准） 多线程（4线程并发） 2.66s 4.41x 实验结论：IO 等待期间，操作系统内核接管了网络通信，Python 线程无需执行字节码，CPython 会主动释放 GIL（通过底层 select/epoll 系统调用）。因此 4 个线程可以真正并发等待，总时间约等于单次请求时间，加速比超过了理想的 4x（网络抖动导致单线程耗时略有偏差）。\n小结 任务类型 多线程效果 原因 CPU 密集型（纯 Python 循环） 几乎无加速，甚至变慢 GIL 阻止多线程并行执行字节码 IO 密集型（网络/文件读写） 接近线性加速 IO 等待时 GIL 主动释放，其他线程可运行 三、GIL 影响哪些代码？ 这是最容易被误解的部分。GIL 并不是说\u0026quot;多线程全部没用\u0026quot;，而是有明确的边界。\n受 GIL 影响的代码 以下操作每一步都在执行 Python 字节码，GIL 全程持有：\n纯 Python 循环：for x in list: ...、while 循环中的数值计算 pandas 字符串操作：df['col'].str.replace()、.str.split() 等（底层走 Python 对象，非 C 扩展） np.vectorize：虽然名字叫向量化，但本质是对 Python 函数逐元素调用，GIL 全程持有 object dtype 的 NumPy 数组：存储 Python 对象引用，每次访问都要操作引用计数 import numpy as np # 陷阱：object dtype 数组不是真正的向量化 arr = np.array([1, \u0026#34;hello\u0026#34;, 3.14], dtype=object) # object dtype result = np.vectorize(lambda x: str(x))(arr) # 逐元素调用 Python 函数，受 GIL 限制 不受 GIL 影响的代码 以下操作在 C/C++ 层执行，进入时主动调用 Py_BEGIN_ALLOW_THREADS 释放 GIL：\nNumPy 内置运算：np.sum()、np.dot()、矩阵运算等（底层 BLAS/LAPACK） PyTorch / TensorFlow 张量运算：所有算子均在 C++/CUDA 层执行 文件/网络 IO：open()、socket、urllib、requests 等 pandas 的 rolling、groupby 等聚合操作：底层由 Cython 实现 关键原理：NumPy 等库在进入 C 层时调用的这两个宏：\n// NumPy 源码中典型的 GIL 释放模式 Py_BEGIN_ALLOW_THREADS // 释放 GIL，其他 Python 线程可以运行 /* ... 纯 C 计算，不操作任何 Python 对象 ... */ Py_END_ALLOW_THREADS // 重新获取 GIL 实验3：受影响 vs 不受影响的直观对比 任务：对 500 万个元素的数组求和，分别用纯 Python 循环和 np.sum()，各用 4 个线程并发执行。\nimport threading import time import numpy as np N = 5_000_000 THREADS = 4 def python_sum(arr: list) -\u0026gt; float: \u0026#34;\u0026#34;\u0026#34;用纯 Python 循环累加，每一步都在执行 Python 字节码，GIL 全程持有。\u0026#34;\u0026#34;\u0026#34; total = 0.0 for x in arr: total += x return total def numpy_sum(arr: np.ndarray) -\u0026gt; float: \u0026#34;\u0026#34;\u0026#34;调用 np.sum()，计算在 C 层执行，进入时释放 GIL，多线程可并行。\u0026#34;\u0026#34;\u0026#34; return np.sum(arr) def run_single(func, arg): t0 = time.perf_counter() for _ in range(THREADS): func(arg) return time.perf_counter() - t0 def run_multi(func, arg): threads = [threading.Thread(target=func, args=(arg,)) for _ in range(THREADS)] t0 = time.perf_counter() for t in threads: t.start() for t in threads: t.join() return time.perf_counter() - t0 py_data = list(range(N)) np_data = np.arange(N, dtype=np.float64) py_single = run_single(python_sum, py_data) py_multi = run_multi(python_sum, py_data) np_single = run_single(numpy_sum, np_data) np_multi = run_multi(numpy_sum, np_data) 实验结果（数组大小 500 万，线程数 4）：\n方案 单线程耗时 多线程耗时 加速比 纯 Python 循环 0.29s 0.27s 1.07x NumPy np.sum() 0.01s \u0026lt;0.01s 4.94x 实验结论：纯 Python 循环的多线程加速比仅 1.07x（GIL 使并发退化为串行），NumPy 向量化的多线程加速比达 4.94x（超过核数，源于硬件层的 SIMD 并行）。两者单线程性能差距就高达 29 倍（0.29s vs 0.01s）。这印证了\u0026quot;向量化不只是更快的循环，它让计算逃离了 GIL 的管辖范围\u0026quot;。\n四、避免 GIL 影响的策略 4.1 通用场景 策略A：向量化（NumPy / Pandas） 原理：将 Python 循环替换为 NumPy/Pandas 内置运算。这些运算在 C 层执行时调用 Py_BEGIN_ALLOW_THREADS 释放 GIL，因此：①单线程下就比 Python 循环快几十倍（SIMD/BLAS 优化）；②多线程环境中能真正并行。\n最常见的陷阱是把 Python 函数传给 np.vectorize——这不是向量化，本质是个 for 循环的语法糖：\nimport numpy as np # ❌ 陷阱：np.vectorize 是 for 循环的语法糖，受 GIL 限制 result = np.vectorize(lambda x: x ** 2)(arr) # ✅ 正确：直接用 NumPy 广播，在 C 层执行 result = arr ** 2 import threading import time import numpy as np N = 5_000_000 THREADS = 4 def python_sum(arr: list) -\u0026gt; float: total = 0.0 for x in arr: total += x return total def numpy_sum(arr: np.ndarray) -\u0026gt; float: return np.sum(arr) def run_single(func, arg): t0 = time.perf_counter() for _ in range(THREADS): func(arg) return time.perf_counter() - t0 def run_multi(func, arg): threads = [threading.Thread(target=func, args=(arg,)) for _ in range(THREADS)] t0 = time.perf_counter() for t in threads: t.start() for t in threads: t.join() return time.perf_counter() - t0 if __name__ == \u0026#34;__main__\u0026#34;: py_data = list(range(N)) np_data = np.arange(N, dtype=np.float64) py_single = run_single(python_sum, py_data) py_multi = run_multi(python_sum, py_data) print(f\u0026#34;Python 循环 - 单线程：{py_single:.2f}s，多线程：{py_multi:.2f}s，加速比：{py_single/py_multi:.2f}x\u0026#34;) np_single = run_single(numpy_sum, np_data) np_multi = run_multi(numpy_sum, np_data) print(f\u0026#34;NumPy sum - 单线程：{np_single:.2f}s，多线程：{np_multi:.2f}s，加速比：{np_single/np_multi:.2f}x\u0026#34;) 实验结论：见上方实验3结果。NumPy 向量化在多线程下实现 4.94x 加速比，而 Python 循环仅 1.07x。\n策略B：multiprocessing（每进程独立 GIL） 原理：multiprocessing 不是绕开 GIL，而是彻底逃离它。每个子进程有独立的 Python 解释器实例，因而有独立的 GIL、独立的内存空间。多个进程可以在多个 CPU 核上真正并行地执行 Python 字节码。\n代价：进程启动开销（fork + import）约 100ms~几百ms；进程间传递数据需要 pickle 序列化，大对象有额外成本。\nimport threading import multiprocessing import time def max_collatz_steps(limit: int) -\u0026gt; int: \u0026#34;\u0026#34;\u0026#34;计算 [1, limit) 中 Collatz 序列最长的步数。纯 Python 数值运算。\u0026#34;\u0026#34;\u0026#34; def steps(n): count = 0 while n != 1: n = n // 2 if n % 2 == 0 else 3 * n + 1 count += 1 return count return max(steps(n) for n in range(1, limit)) LIMIT = 200_000 WORKERS = 4 def run_single(): t0 = time.perf_counter() for _ in range(WORKERS): max_collatz_steps(LIMIT) return time.perf_counter() - t0 def run_threads(): threads = [threading.Thread(target=max_collatz_steps, args=(LIMIT,)) for _ in range(WORKERS)] t0 = time.perf_counter() for t in threads: t.start() for t in threads: t.join() return time.perf_counter() - t0 def run_processes(): with multiprocessing.Pool(processes=WORKERS) as pool: t0 = time.perf_counter() pool.starmap(max_collatz_steps, [(LIMIT,)] * WORKERS) elapsed = time.perf_counter() - t0 return elapsed if __name__ == \u0026#34;__main__\u0026#34;: t_single = run_single() t_thread = run_threads() t_process = run_processes() print(f\u0026#34;单线程：{t_single:.2f}s\u0026#34;) print(f\u0026#34;多线程：{t_thread:.2f}s，加速比：{t_single/t_thread:.2f}x\u0026#34;) print(f\u0026#34;多进程：{t_process:.2f}s，加速比：{t_single/t_process:.2f}x\u0026#34;) 实验结果（Collatz 序列，limit=200,000，重复 4 次）：\n方案 耗时 加速比 单线程 3.03s 1x（基准） 多线程（4线程） 2.93s 1.03x 多进程（4进程） 1.02s 2.97x 实验结论：多线程加速比仅 1.03x，多进程实现了 2.97x 接近线性的加速。与理想 4x 的差距来自进程启动和 IPC 开销，在任务规模足够大时这部分开销可以忽略不计。\n使用建议：任务粒度 \u0026gt; 几百毫秒时用多进程；更小的任务优先用向量化；不要盲目用 multiprocessing 处理每个小任务，进程启动开销会淹没收益。\n策略C：asyncio（IO 密集型最优解） 原理：asyncio 是协作式单线程并发。所有协程运行在同一个线程，轮流通过 await 把控制权交还给事件循环。没有多线程的 GIL 竞争，也没有多进程的启动开销。\n它不能加速 CPU 密集型任务（仍是单线程），但对于 IO 密集型任务（大量网络请求、数据库查询、文件读写）是最轻量的解决方案：\nimport asyncio import aiohttp import time async def fetch(session: aiohttp.ClientSession, url: str) -\u0026gt; int: \u0026#34;\u0026#34;\u0026#34;协程：发起 HTTP 请求，await 时主动释放控制权给事件循环。\u0026#34;\u0026#34;\u0026#34; async with session.get(url) as resp: content = await resp.read() return len(content) async def main(): URL = \u0026#34;https://httpbin.org/delay/1\u0026#34; N = 10 # 并发请求数 async with aiohttp.ClientSession() as session: t0 = time.perf_counter() tasks = [fetch(session, URL) for _ in range(N)] results = await asyncio.gather(*tasks) # 并发等待所有请求 elapsed = time.perf_counter() - t0 print(f\u0026#34;并发 {N} 个请求（每个约 1 秒）：耗时 {elapsed:.2f}s\u0026#34;) print(f\u0026#34;加速比：{N / elapsed:.1f}x（理想为 {N}x）\u0026#34;) if __name__ == \u0026#34;__main__\u0026#34;: asyncio.run(main()) 对比三种 IO 并发方案：\n方案 适用场景 GIL 问题 开销 threading IO 密集 IO 等待时自动释放 线程切换开销，有竞态风险 asyncio IO 密集 单线程，无 GIL 问题 极低，需要 async/await 语法 multiprocessing CPU 密集 独立 GIL 进程启动 + 序列化开销 结论：IO 密集型任务首选 asyncio，代码更简洁，资源消耗更低；CPU 密集型任务用 multiprocessing。\n4.2 深度学习场景 策略D：DataLoader num_workers 训练深度学习模型时，数据预处理（图像解码、增强、归一化）通常运行在 CPU 上，而 GPU 做前向/反向传播。如果数据加载跟不上 GPU 计算，训练会被卡住等数据——这就是\u0026quot;数据瓶颈\u0026quot;。\n原理：DataLoader 的 num_workers 参数启动的是子进程（不是线程），每个 worker 进程独立运行，有独立的 GIL，可在多核上并行执行预处理。\n⚠️ 实验设计的关键：数据必须在磁盘上\n如果数据集全在内存（如 numpy array），num_workers \u0026gt; 0 反而会更慢。 原因是子进程无法直接访问主进程的内存，数据必须通过 IPC（进程间通信）序列化传递， 这个开销在数据已在内存的情况下完全是额外负担。\n真实场景中图像从磁盘读取，此时磁盘 IO + CPU 预处理才是瓶颈， 多进程并行预取才能让 GPU 不空等数据。\nimport os, time, shutil, tempfile import numpy as np import torch from torch.utils.data import Dataset, DataLoader def prepare_dataset_on_disk(size, image_hw, data_dir): \u0026#34;\u0026#34;\u0026#34;将随机图像保存为独立 .npy 文件，模拟真实磁盘数据集。\u0026#34;\u0026#34;\u0026#34; os.makedirs(data_dir, exist_ok=True) for i in range(size): img = np.random.randint(0, 256, (3, image_hw, image_hw), dtype=np.uint8) np.save(os.path.join(data_dir, f\u0026#34;{i:05d}.npy\u0026#34;), img) class DiskImageDataset(Dataset): \u0026#34;\u0026#34;\u0026#34; 每次 __getitem__ 从磁盘读取一个 .npy 文件，再做 CPU 预处理。 这才是 num_workers 有效的前提： - 磁盘读取是 IO 操作（GIL 在系统调用时会释放） - numpy 解码 + 数据增强是 CPU 计算（多进程可并行） \u0026#34;\u0026#34;\u0026#34; def __init__(self, data_dir): self.files = sorted([ os.path.join(data_dir, f) for f in os.listdir(data_dir) if f.endswith(\u0026#34;.npy\u0026#34;) ]) def __len__(self): return len(self.files) def __getitem__(self, idx): img = np.load(self.files[idx]).astype(np.float32) # 磁盘读取 if np.random.rand() \u0026gt; 0.5: img = img[:, :, ::-1].copy() img = img / 255.0 mean = np.array([0.485, 0.456, 0.406], dtype=np.float32).reshape(3, 1, 1) std = np.array([0.229, 0.224, 0.225], dtype=np.float32).reshape(3, 1, 1) img = (img - mean) / std img = np.clip(img * 1.1 + np.random.normal(0, 0.01, img.shape).astype(np.float32), -3, 3) return torch.from_numpy(img.copy()), idx % 10 def benchmark_dataloader(num_workers, dataset, batch_size=32): loader = DataLoader( dataset, batch_size=batch_size, num_workers=num_workers, shuffle=True, pin_memory=False, prefetch_factor=2 if num_workers \u0026gt; 0 else None, persistent_workers=True if num_workers \u0026gt; 0 else False, ) loader_iter = iter(loader) next(loader_iter) # 预热：让 worker 进程完成 fork + import t0 = time.perf_counter() for batch_imgs, _ in loader_iter: _ = batch_imgs.mean() return time.perf_counter() - t0 if __name__ == \u0026#34;__main__\u0026#34;: data_dir = os.path.join(tempfile.gettempdir(), \u0026#34;gil_fake_images\u0026#34;) try: prepare_dataset_on_disk(1000, 128, data_dir) dataset = DiskImageDataset(data_dir) for nw in [0, 1, 2, 4]: t = benchmark_dataloader(nw, dataset) print(f\u0026#34;num_workers={nw}：{t:.2f}s\u0026#34;) finally: shutil.rmtree(data_dir) 实验结果（1000 张 128×128 图像，从磁盘读取，batch_size=32）：\nnum_workers 耗时 加速比（vs 0） 说明 0 1.38s 1x（基准） 主进程串行，受 GIL 限制 1 0.82s 1.68x 1 个 worker 子进程，独立 GIL 2 0.48s 2.87x 2 个 worker 子进程 4 0.41s 3.36x 4 个 worker 子进程 内存数据集（旧版）对比：num_workers=0 约 6.83s，num_workers=4 约 27.29s — workers 越多越慢（IPC 开销 \u0026gt; 并行收益）。\n实验结论：num_workers 的提速效果取决于数据在哪里。数据在磁盘时，worker 子进程并行执行「读盘 → 解码 → 增强」，主进程做 forward 时已在预取下一批，GPU 不会等数据；数据在内存时，IPC 序列化开销反而拖慢速度。注意加速比从 num_workers=2 到 4 趋于平缓（从 2.87x→3.36x），磁盘 IO 成为新的瓶颈。经验法则：num_workers = min(4, CPU核数 // 2)，配合 persistent_workers=True 避免每个 epoch 重建进程。\n策略E：大规模特征提取向量化 训练完模型后，对大量样本提取 embedding（图像检索、语义相似度）是常见需求。典型的错误写法是逐样本预处理然后推理，正确做法是向量化预处理后批量推理。\n向量化的核心思路：把对单个样本的操作改写成对整个矩阵的广播运算。\n# 串行版本：N_SAMPLES 次 Python 循环 for x in raw_data: feat = x / norm(x) # 每次操作一个向量 # 向量化版本：一次操作整个矩阵 feat = raw_data / norm(raw_data, axis=1, keepdims=True) NumPy 的广播操作在 C 层执行，GIL 释放，无 Python 循环。对于 50,000 × 2048 的矩阵，这个差距非常显著。\nimport time, threading, concurrent.futures import numpy as np import torch import torch.nn as nn class Encoder(nn.Module): def __init__(self, input_dim=2048, hidden_dim=2048, embed_dim=256): super().__init__() self.net = nn.Sequential( nn.Linear(input_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, embed_dim), ) def forward(self, x): return self.net(x) INPUT_DIM = 2048 # 模拟 ResNet 最后一层特征展平 EMBED_DIM = 256 N_SAMPLES = 50_000 # 足够大，才能体现向量化优势 BATCH_SIZE = 256 WORKERS = 4 def preprocess_one(raw: np.ndarray) -\u0026gt; torch.Tensor: \u0026#34;\u0026#34;\u0026#34;对单个样本做预处理：L2 归一化 + 随机 dropout + clip。\u0026#34;\u0026#34;\u0026#34; feat = raw.astype(np.float32) norm = np.linalg.norm(feat) + 1e-8 feat = feat / norm mask = (np.random.rand(len(feat)) \u0026gt; 0.1).astype(np.float32) feat = feat * mask feat = np.clip(feat, -5.0, 5.0) return torch.from_numpy(feat) def extract_serial(model, raw_data): \u0026#34;\u0026#34;\u0026#34;串行逐样本预处理：Python for 循环，GIL 全程持有。\u0026#34;\u0026#34;\u0026#34; model.eval() embeddings = [] t0 = time.perf_counter() with torch.no_grad(): for i in range(0, N_SAMPLES, BATCH_SIZE): chunk = raw_data[i:i+BATCH_SIZE] batch = torch.stack([preprocess_one(x) for x in chunk]) embeddings.append(model(batch)) return time.perf_counter() - t0, torch.cat(embeddings) def extract_vectorized(model, raw_data): \u0026#34;\u0026#34;\u0026#34; NumPy 向量化批量预处理：无任何 Python 循环。 将 preprocess_one 的所有操作改写为对整个矩阵的广播运算。 \u0026#34;\u0026#34;\u0026#34; model.eval() t0 = time.perf_counter() feat = raw_data.astype(np.float32) # L2 归一化（axis=1 广播，操作整个矩阵） norms = np.linalg.norm(feat, axis=1, keepdims=True) + 1e-8 feat = feat / norms # 向量化 feature dropout（一次生成整个 mask 矩阵） mask = (np.random.rand(N_SAMPLES, INPUT_DIM) \u0026gt; 0.1).astype(np.float32) feat = feat * mask feat = np.clip(feat, -5.0, 5.0) all_tensors = torch.from_numpy(feat) embeddings = [] with torch.no_grad(): for i in range(0, N_SAMPLES, BATCH_SIZE): embeddings.append(model(all_tensors[i:i+BATCH_SIZE])) return time.perf_counter() - t0, torch.cat(embeddings) 实验结果（50,000 个样本，dim=2048，3层 MLP hidden=2048，提取 256d embedding）：\n策略 耗时 加速比 串行逐样本预处理（基准） 46.58s 1x 多线程预处理 19.79s 2.35x（GIL 限制 Python 循环，提升有限） 多进程预处理 8.79s 5.30x（绕开 GIL，但 IPC 传数组有开销） NumPy 向量化批量 3.28s 14.19x 实验结论：向量化是这四种方案中唯一从根本上消除 Python 循环的方法——不是\u0026quot;更快的 Python 循环\u0026quot;，而是将 50,000 次 Python 调用压缩为一次矩阵广播，由 C/NumPy 内核执行。\n加速比拆解：\n预处理：Python 循环 50,000 次 → NumPy 矩阵广播 1 次，差距在 10~100x 量级 推理：两者都是 batch PyTorch forward，速度相同 多线程 2.35x：GIL 让线程无法真正并行，仅靠 NumPy 内部少量 GIL 释放获得微小收益 多进程 5.30x：真正绕开 GIL，但 IPC 序列化 50K × 2048 float 数组有显著开销 向量化 14.19x：零并发，零进程开销，纯靠消除 Python 循环 4.3 量化金融场景 策略G：Alpha 因子 joblib 并行 量化研究中，对数百支股票同时计算技术因子（动量、波动率、RSI 等）是最典型的 GIL 受害场景：每支股票的计算彼此独立，天然可并行，但如果用 Python 循环实现，多线程几乎没用。\nimport time import threading import numpy as np from joblib import Parallel, delayed N_STOCKS = 500 N_DAYS = 252 WORKERS = 4 def generate_price_data(n_stocks=N_STOCKS, n_days=N_DAYS) -\u0026gt; np.ndarray: returns = np.random.normal(0.0005, 0.02, (n_stocks, n_days)) return 100 * np.exp(np.cumsum(returns, axis=1)) def compute_factors_for_one_stock(prices: np.ndarray) -\u0026gt; dict: \u0026#34;\u0026#34;\u0026#34;纯 Python 循环实现 5 个技术因子（动量、波动率、RSI 等）。\u0026#34;\u0026#34;\u0026#34; n = len(prices) mom_20 = [prices[i] / prices[i-20] - 1 for i in range(20, n)] mom_60 = [prices[i] / prices[i-60] - 1 for i in range(60, n)] returns = [prices[i] / prices[i-1] - 1 for i in range(1, n)] vol_20 = [] for i in range(20, len(returns)): w = returns[i-20:i] mean = sum(w) / 20 vol_20.append((sum((x-mean)**2 for x in w) / 20) ** 0.5) return { \u0026#34;mom_20\u0026#34;: np.mean(mom_20) if mom_20 else 0, \u0026#34;mom_60\u0026#34;: np.mean(mom_60) if mom_60 else 0, \u0026#34;vol_20\u0026#34;: np.mean(vol_20) if vol_20 else 0, } def run_vectorized(prices: np.ndarray) -\u0026gt; float: \u0026#34;\u0026#34;\u0026#34;NumPy/Pandas 矩阵化计算所有股票的所有因子。\u0026#34;\u0026#34;\u0026#34; import pandas as pd t0 = time.perf_counter() returns = prices[:, 1:] / prices[:, :-1] - 1 mom_20 = (prices[:, 20:] / prices[:, :-20] - 1).mean(axis=1) mom_60 = (prices[:, 60:] / prices[:, :-60] - 1).mean(axis=1) df_returns = pd.DataFrame(returns.T) vol_20 = df_returns.rolling(20).std().mean().values return time.perf_counter() - t0 if __name__ == \u0026#34;__main__\u0026#34;: prices = generate_price_data() t1 = time.perf_counter() [compute_factors_for_one_stock(prices[i]) for i in range(N_STOCKS)] t1 = time.perf_counter() - t1 t2 = time.perf_counter() results = [None] * N_STOCKS def worker(i): results[i] = compute_factors_for_one_stock(prices[i]) threads = [threading.Thread(target=worker, args=(i,)) for i in range(N_STOCKS)] for t in threads: t.start() for t in threads: t.join() t2 = time.perf_counter() - t2 t3 = time.perf_counter() Parallel(n_jobs=WORKERS)(delayed(compute_factors_for_one_stock)(prices[i]) for i in range(N_STOCKS)) t3 = time.perf_counter() - t3 t4 = run_vectorized(prices) print(f\u0026#34;串行：{t1:.2f}s | 多线程：{t2:.2f}s | joblib：{t3:.2f}s | 向量化：{t4:.2f}s\u0026#34;) 实验结果（500 支股票，252 天历史数据，5 个技术因子）：\n策略 耗时 加速比 串行（基准） 0.72s 1x 多线程（500 线程） 0.72s 1.00x joblib 多进程（4 workers） 0.39s 1.86x NumPy 向量化 0.03s 28.28x 实验结论：多线程 1.00x——GIL 完全抵消了并发收益。joblib 多进程实现 1.86x 加速（500 个任务分给 4 个进程，有调度开销）。NumPy 向量化以 28x 的优势碾压其他方案——将 500 支股票的逐股计算改写成 500×252 矩阵运算，同时获得了\u0026quot;消除 Python 循环\u0026quot;和\u0026quot;GIL 释放\u0026quot;两重收益。这是量化研究中最值得投入时间的重写方向。\njoblib 使用技巧：n_jobs=-1 使用所有 CPU 核；backend=\u0026quot;loky\u0026quot; 是默认的进程后端；当任务非常多但单个任务很小时，可设置 batch_size='auto' 减少调度开销。\n策略H：蒙特卡洛向量化定价 期权定价（Black-Scholes 模型下的蒙特卡洛方法）需要模拟大量股价路径，每条路径独立，是天然的并行场景。\nimport time import math import concurrent.futures import numpy as np S0, K, T, r, sigma = 100.0, 105.0, 1.0, 0.05, 0.2 N, M, WORKERS = 252, 100_000, 4 def simulate_one_path_python(seed: int) -\u0026gt; float: rng = np.random.default_rng(seed) dt = T / N S = S0 for _ in range(N): z = rng.standard_normal() S *= math.exp((r - 0.5 * sigma**2) * dt + sigma * math.sqrt(dt) * z) return S def run_serial(): t0 = time.perf_counter() payoffs = [max(simulate_one_path_python(i) - K, 0) for i in range(M)] price = math.exp(-r * T) * sum(payoffs) / M return time.perf_counter() - t0, price def run_vectorized(): \u0026#34;\u0026#34;\u0026#34;精确终价公式：S_T = S0 * exp((r - 0.5σ²)T + σ√T·Z)，Z~N(0,1)\u0026#34;\u0026#34;\u0026#34; t0 = time.perf_counter() Z = np.random.standard_normal(M) S_T = S0 * np.exp((r - 0.5 * sigma**2) * T + sigma * math.sqrt(T) * Z) payoffs = np.maximum(S_T - K, 0.0) price = math.exp(-r * T) * payoffs.mean() return time.perf_counter() - t0, price 实验结果（欧式看涨期权，10 万条路径，S0=100, K=105, T=1y, σ=0.2）：\nBS 解析价格：8.0214\n策略 耗时 期权价格 加速比 串行（Python 循环） 7.03s 8.0756 1x 多进程（4 workers） 1.88s 8.0756 3.74x NumPy 向量化（精确终价） \u0026lt;0.01s 7.9821 ~3430x NumPy 向量化（逐步路径 M×N） 0.32s 7.9756 22.30x 实验结论：四种方法的期权价格都接近 BS 解析解 8.0214（验证了正确性）。精确终价公式（方式A）以 ~3430x 的加速比彻底击败其他方案：利用 GBM 的解析解 $S_T = S_0 \\cdot e^{(r - \\frac{\\sigma^2}{2})T + \\sigma\\sqrt{T}Z}$，一次生成 10 万个终价，无离散化误差，也无进程开销。对于欧式期权，这是生产环境的唯一正确选择；路径依赖期权（亚式、障碍期权）才需要逐步路径模拟。\n策略I：向量化回测 回测是量化研究的核心。事件驱动回测（逐根 K 线循环）是最\u0026quot;自然\u0026quot;的写法，也是 GIL 的重灾区；向量化回测（Pandas rolling + NumPy 信号向量）则从根本上消除了 Python 循环。这个案例还揭示了一个有趣的反直觉结论：向量化让单次回测快到 ~0.001s，以至于多线程反而成为负担。\nimport time, threading, concurrent.futures import numpy as np import pandas as pd N_DAYS = 5000 N_STOCKS = 100 THREADS = 4 def event_driven_backtest(df: pd.DataFrame) -\u0026gt; dict: \u0026#34;\u0026#34;\u0026#34;逐根 K 线执行双均线策略，纯 Python 循环。\u0026#34;\u0026#34;\u0026#34; close = df[\u0026#34;close\u0026#34;].values n = len(close) cash, shares, nav = 10000.0, 0, [] for i in range(20, n): ma5 = sum(close[i-5:i]) / 5 # Python sum()，每步都是字节码 ma20 = sum(close[i-20:i]) / 20 prev_ma5 = sum(close[i-6:i-1]) / 5 prev_ma20 = sum(close[i-21:i-1]) / 20 price = close[i] if ma5 \u0026gt; ma20 and prev_ma5 \u0026lt;= prev_ma20 and cash \u0026gt;= price: n_buy = int(cash / price) shares += n_buy; cash -= n_buy * price elif ma5 \u0026lt; ma20 and prev_ma5 \u0026gt;= prev_ma20 and shares \u0026gt; 0: cash += shares * price; shares = 0 nav.append(cash + shares * price) return {\u0026#34;total_return\u0026#34;: nav[-1] / 10000.0 - 1 if nav else 0} def vectorized_backtest(df: pd.DataFrame) -\u0026gt; dict: \u0026#34;\u0026#34;\u0026#34;用 Pandas rolling 计算均线，NumPy 向量化生成信号。\u0026#34;\u0026#34;\u0026#34; close = df[\u0026#34;close\u0026#34;] ma5, ma20 = close.rolling(5).mean(), close.rolling(20).mean() cross_up = (ma5 \u0026gt; ma20) \u0026amp; (ma5.shift(1) \u0026lt;= ma20.shift(1)) cross_down = (ma5 \u0026lt; ma20) \u0026amp; (ma5.shift(1) \u0026gt;= ma20.shift(1)) signal = pd.Series(np.nan, index=df.index) signal[cross_up] = 1; signal[cross_down] = 0 signal = signal.ffill().fillna(0) strategy_return = (signal.shift(1) * close.pct_change()).fillna(0) total_return = (1 + strategy_return).prod() - 1 return {\u0026#34;total_return\u0026#34;: float(total_return)} def run_all_serial(backtest_fn, all_data): t0 = time.perf_counter() results = [backtest_fn(df) for df in all_data] return time.perf_counter() - t0, results def run_all_threaded(backtest_fn, all_data): \u0026#34;\u0026#34;\u0026#34;使用 ThreadPoolExecutor（THREADS 个 worker）并行回测所有股票。\u0026#34;\u0026#34;\u0026#34; t0 = time.perf_counter() with concurrent.futures.ThreadPoolExecutor(max_workers=THREADS) as executor: results = list(executor.map(backtest_fn, all_data)) return time.perf_counter() - t0, results 实验结果（100 支股票，5000 根 K 线，双均线策略，4 workers）：\n策略 串行耗时 多线程耗时（4 workers） 多线程加速比 事件驱动（Python 循环） 1.70s 1.67s 1.02x 向量化（Pandas + NumPy） 0.08s 0.10s 0.82x 向量化 vs 事件驱动（串行）：~20x 加速\n实验结论：\n事件驱动多线程加速比 ~1x：GIL 把并行收益完全抹平，100 支股票的 Python 循环无法真正并行 向量化多线程 0.82x，反而更慢：每支股票的向量化回测只需 ~0.001s（0.08s ÷ 100），这比线程池的调度开销还小；此时并发反而是负担，串行就是最优解 向量化串行（0.08s）比事件驱动多线程（1.67s）快 ~20x——单纯改写算法的收益远大于任何并发方案 核心结论：向量化让每支股票的计算变得极其廉价，以至于多线程的收益空间消失了。并行加速的前提是单个任务足够重（通常 \u0026gt;10ms）；当任务已经被向量化压缩到亚毫秒级，进一步并行得不偿失 事件驱动的优势在于逻辑灵活（复杂仓位管理、滑点模型、条件单），不在性能 4.4 自定义算子加速 前面的策略有一个共同前提：计算可以被表达成向量化形式，或任务间彼此独立。但有一类运算天然打破这个前提——递推计算：每一步的结果依赖上一步，无法并行或广播。\n最典型的例子是 EMA（指数加权移动平均），在量化金融中无处不在：\n$$\\text{EMA}[i] = \\alpha \\cdot x[i] + (1-\\alpha) \\cdot \\text{EMA}[i-1]$$这个递推无法向量化，但可以把它下沉到 C 层，并在 C 层主动释放 GIL。下面介绍三种实现路径，复杂度依次递增，性能相近。\n策略J：Numba JIT（@jit nogil=True） 原理：Numba 使用 LLVM 将带类型的 Python/NumPy 函数在首次调用时编译为机器码。加上 nogil=True 参数后，编译后的函数在执行期间会释放 GIL，多线程可真正并行。关键前提是必须同时设置 nopython=True——这保证函数内部完全不操作 Python 对象，才能安全地释放 GIL。\nfrom numba import jit # ❌ 不释放 GIL：单线程快，多线程仍串行 @jit(nopython=True, nogil=False) def ema_numba(close, alpha): ... # ✅ 释放 GIL：单线程一样快，多线程真正并行 @jit(nopython=True, nogil=True) def ema_numba_nogil(close, alpha): ... import threading import time import numpy as np from numba import jit N = 1_000_000 PERIOD = 20 THREADS = 4 alpha = 2.0 / (PERIOD + 1) def ema_python(close: np.ndarray, alpha: float) -\u0026gt; np.ndarray: \u0026#34;\u0026#34;\u0026#34;纯 Python 递推，每步都是 Python 字节码，受 GIL 严格限制。\u0026#34;\u0026#34;\u0026#34; result = np.empty(len(close)) result[0] = close[0] for i in range(1, len(close)): result[i] = alpha * close[i] + (1 - alpha) * result[i - 1] return result @jit(nopython=True, nogil=True) def ema_numba_nogil(close: np.ndarray, alpha: float) -\u0026gt; np.ndarray: \u0026#34;\u0026#34;\u0026#34; nogil=True：函数执行期间释放 GIL。 nopython=True 确保函数内部不操作任何 Python 对象，释放 GIL 才安全。 多个线程可以真正并行地各自执行此函数。 \u0026#34;\u0026#34;\u0026#34; result = np.empty(len(close)) result[0] = close[0] for i in range(1, len(close)): result[i] = alpha * close[i] + (1 - alpha) * result[i - 1] return result def run_single(func, data, repeat=THREADS): t0 = time.perf_counter() for _ in range(repeat): func(data, alpha) return time.perf_counter() - t0 def run_multi(func, data, n_threads=THREADS): threads = [threading.Thread(target=func, args=(data, alpha)) for _ in range(n_threads)] t0 = time.perf_counter() for t in threads: t.start() for t in threads: t.join() return time.perf_counter() - t0 if __name__ == \u0026#34;__main__\u0026#34;: close = np.random.rand(N).astype(np.float64) * 100 + 50 # 预热：触发 JIT 编译（首次调用有延迟，不计入计时） ema_numba_nogil(close[:100], alpha) t_py_s = run_single(ema_python, close) t_nb_s = run_single(ema_numba_nogil, close) t_nb_m = run_multi(ema_numba_nogil, close) print(f\u0026#34;纯 Python 单线程：{t_py_s:.3f}s\u0026#34;) print(f\u0026#34;Numba nogil 单线程：{t_nb_s:.3f}s（vs Python: {t_py_s/t_nb_s:.1f}x）\u0026#34;) print(f\u0026#34;Numba nogil 多线程：{t_nb_m:.3f}s（多线程加速比: {t_nb_s*THREADS/t_nb_m:.2f}x）\u0026#34;) 实验结果（EMA period=20，数组长度 100 万，重复 4 次）：\n方案 单线程耗时 vs Python 多线程耗时 多线程加速比 纯 Python 循环 0.702s 1x（基准） 0.681s 1.03x Numba nogil=False 0.009s 76.5x 0.010s 0.96x Numba nogil=True 0.009s 77.4x 0.003s 2.98x 实验结论：Numba JIT 单线程比 Python 快 ~77x（LLVM 机器码 vs 字节码解释）。nogil=False 多线程加速比仅 0.96x——编译后的代码虽快，GIL 依然把线程串行化；nogil=True 多线程加速比 2.98x（接近理论 4x，差距来自线程启动和内存带宽竞争）。注意：首次调用有 JIT 编译延迟（约 0.5~2s），适合长期运行的服务而非一次性脚本。\n策略K：Cython（cdef + with nogil） 原理：Cython 是 Python 的超集，通过添加 cdef 静态类型注解编译为 C 扩展（.so 文件）。with nogil: 块显式释放 GIL，编译器会做安全检查——如果块内有任何 Python 对象操作，编译时直接报错，而不是运行时崩溃。这是 pandas、scipy、scikit-learn 内部大量使用的方案，无运行时依赖。\n# ema_cython.pyx import numpy as np cimport numpy as np def fast_ema_nogil(np.ndarray[double, ndim=1] close, int period): \u0026#34;\u0026#34;\u0026#34; 在 with nogil: 块中执行递推，显式释放 GIL。 多线程场景下，多个线程可以同时执行此函数。 注意：with nogil 块内不能有任何 Python 对象操作， 否则 Cython 编译时会报错，这是一个编译期安全检查。 \u0026#34;\u0026#34;\u0026#34; cdef double alpha = 2.0 / (period + 1) cdef double[:] result = np.empty(len(close), dtype=np.float64) cdef double[:] c = close # 先拿到 memoryview，避免 nogil 块内访问 Python 对象 cdef int i cdef int n = len(close) result[0] = c[0] # with nogil 块：释放 GIL，允许其他 Python 线程运行 # 块内只能操作 C 变量和 memoryview，不能有任何 Python 对象 with nogil: for i in range(1, n): result[i] = alpha * c[i] + (1 - alpha) * result[i - 1] return np.asarray(result) # 编译：python setup_cython.py build_ext --inplace from setuptools import setup from Cython.Build import cythonize import numpy as np setup( ext_modules=cythonize( \u0026#34;ema_cython.pyx\u0026#34;, compiler_directives={ \u0026#34;language_level\u0026#34;: \u0026#34;3\u0026#34;, \u0026#34;boundscheck\u0026#34;: False, # 关闭越界检查，提升性能（生产慎用） \u0026#34;wraparound\u0026#34;: False, # 关闭负索引支持 }, ), include_dirs=[np.get_include()], ) 实验结果（相同任务）：\n方案 单线程耗时 vs Python 多线程耗时 多线程加速比 纯 Python 循环 0.679s 1x（基准） 0.698s 0.97x Cython cdef（默认持有 GIL） 0.015s 46.8x 0.015s 0.94x Cython with nogil 0.015s 45.5x 0.005s 3.22x 实验结论：Cython cdef 单线程比 Python 快 ~47x。默认 Cython 多线程加速比仅 0.94x——cdef 消除了 Python 对象，但 GIL 仍由调用方隐式持有；with nogil 显式释放后，多线程加速比 3.22x。Cython 的核心优势：编译产物是标准 .so 文件，无运行时依赖（无需安装 Numba），无首次编译延迟，是 pandas/scipy 的选择。代价是需要维护 .pyx 文件和编译流程。\n策略L：pybind11 C++ 扩展（gil_scoped_release） 原理：pybind11 是现代 C++/Python 绑定库。py::gil_scoped_release 采用 RAII 风格——构造时释放 GIL，析构时（离开作用域时）自动重新获取，无需手动管理。适合对接已有 C++ 代码库，或需要极致性能时直接操作 NumPy buffer。\n#include \u0026lt;pybind11/pybind11.h\u0026gt; #include \u0026lt;pybind11/numpy.h\u0026gt; namespace py = pybind11; py::array_t\u0026lt;double\u0026gt; fast_ema_nogil(py::array_t\u0026lt;double\u0026gt; close, int period) { auto buf = close.request(); double* ptr = static_cast\u0026lt;double*\u0026gt;(buf.ptr); int n = static_cast\u0026lt;int\u0026gt;(buf.shape[0]); double alpha = 2.0 / (period + 1); py::array_t\u0026lt;double\u0026gt; result(n); auto res_buf = result.request(); double* res = static_cast\u0026lt;double*\u0026gt;(res_buf.ptr); // 在释放 GIL 之前，确保所有 Python 对象操作已完成 // gil_scoped_release：RAII 风格，构造时释放 GIL，析构时重新获取 { py::gil_scoped_release release; // GIL 已释放：这里只操作原始 C 指针，不涉及任何 Python 对象 // 多个线程可以同时执行到这里，真正并行 res[0] = ptr[0]; for (int i = 1; i \u0026lt; n; ++i) { res[i] = alpha * ptr[i] + (1.0 - alpha) * res[i - 1]; } } // GIL 在这里自动重新获取 return result; } PYBIND11_MODULE(ema_cpp, m) { m.def(\u0026#34;fast_ema_nogil\u0026#34;, \u0026amp;fast_ema_nogil, py::arg(\u0026#34;close\u0026#34;), py::arg(\u0026#34;period\u0026#34;), \u0026#34;EMA 递推（释放 GIL，多线程可并行）\u0026#34;); } import threading, time import numpy as np import ema_cpp # 编译自 ema_cpp.cpp N = 1_000_000 PERIOD = 20 THREADS = 4 def run_multi(func, data, period, n_threads=THREADS): threads = [threading.Thread(target=func, args=(data, period)) for _ in range(n_threads)] t0 = time.perf_counter() for t in threads: t.start() for t in threads: t.join() return time.perf_counter() - t0 close = np.random.rand(N).astype(np.float64) * 100 + 50 t_pb_m = run_multi(ema_cpp.fast_ema_nogil, close, PERIOD) print(f\u0026#34;pybind11 nogil 多线程（{THREADS} 线程）：{t_pb_m:.3f}s\u0026#34;) 实验结果（相同任务）：\n方案 单线程耗时 vs Python 多线程耗时 多线程加速比 纯 Python 循环 0.684s 1x（基准） 0.676s 1.01x pybind11 C++（持有 GIL） 0.010s 69.1x 0.010s 0.99x pybind11 gil_scoped_release 0.010s 70.6x 0.003s 3.11x 实验结论：pybind11 C++ 单线程比 Python 快 ~70x，与 Numba/Cython 处于同一量级。持有 GIL 版本多线程 0.99x——C++ 代码本身不操作 Python 对象，但 GIL 由调用框架持有，线程仍被串行化；gil_scoped_release 释放后多线程加速比 3.11x。pybind11 的核心优势：可无缝对接已有 C++ 库（交易所 SDK、数值计算库），无需将现有逻辑重写为 Cython。代价是需要 C++ 编译器。\n三种方案横向对比 import time, threading import numpy as np from numba import jit import ema_cython, ema_cpp N, PERIOD, THREADS = 1_000_000, 20, 4 @jit(nopython=True, nogil=True) def ema_numba(close, period): a = 2.0 / (period + 1) result = np.empty(len(close)) result[0] = close[0] for i in range(1, len(close)): result[i] = a * close[i] + (1 - a) * result[i - 1] return result def ema_python(close, period): a = 2.0 / (period + 1) result = np.empty(len(close)) result[0] = close[0] for i in range(1, len(close)): result[i] = a * close[i] + (1 - a) * result[i - 1] return result def bench(func, data, period, repeat=5): \u0026#34;\u0026#34;\u0026#34;多次运行取最小值，减少系统噪声。\u0026#34;\u0026#34;\u0026#34; return min( (lambda: (t := time.perf_counter(), func(data, period), time.perf_counter() - t)[2])() for _ in range(repeat) ) def bench_multi(func, data, period, n_threads=THREADS): threads = [threading.Thread(target=func, args=(data, period)) for _ in range(n_threads)] t0 = time.perf_counter() for t in threads: t.start() for t in threads: t.join() return time.perf_counter() - t0 汇总对比表（EMA period=20，数组长度 100 万）：\n方案 单线程耗时 vs Python 多线程耗时 多线程加速比 Python 循环 0.1709s 1.0x 0.7101s 1.0x Numba nogil=True 0.0022s 76.4x 0.0029s 3.1x Cython with nogil 0.0038s 44.5x 0.0047s 3.2x pybind11 gil_scoped_release 0.0025s 68.2x 0.0032s 3.1x 选型建议：\n场景 推荐方案 理由 快速原型 / 研究脚本 Numba 代码改动最小（加两行装饰器），无需编译步骤 生产库 / 发布给用户 Cython 零运行时依赖，编译期安全检查，pandas/scipy 验证过 对接已有 C++ 代码库 pybind11 直接绑定 C++ 接口，无需重写逻辑 纯 Python 环境限制 multiprocessing 无需编译，但有进程启动开销 五、延伸：Python 3.13 no-GIL 模式（PEP 703） Python 3.13 引入了实验性的 no-GIL 构建（--disable-gil），这是社区多年来期待的重大改变。\n核心变化：\n用细粒度引用计数（biased reference counting）替代 GIL，减少引用计数操作中的竞争 多线程下 CPU 密集型任务可实现真正并行 不需要修改应用层代码 当前限制与注意事项：\n性能回退：no-GIL 模式下，单线程程序速度会下降约 5%~15%（细粒度锁的开销） 生态兼容性：大量 C 扩展库（NumPy、Pandas、PyTorch 等）依赖 GIL 提供的线程安全保证，可能在 no-GIL 模式下出现竞态 bug 仍是实验阶段：CPython 团队明确表示 3.13 的 no-GIL 是\u0026quot;技术预览\u0026quot;，不建议生产使用 需要显式启用：默认构建仍有 GIL，需要使用专门的 no-GIL 构建版本 # 检查当前 Python 是否为 no-GIL 构建 python -c \u0026#34;import sys; print(sys._is_gil_enabled())\u0026#34; # 有 GIL 时输出 True，no-GIL 构建输出 False 实际建议：在 PEP 703 生态完全成熟之前（预计 Python 3.15~3.16），本文介绍的向量化、multiprocessing、asyncio 策略仍是生产环境的可靠选择。no-GIL 模式值得关注，但暂时不建议在生产环境部署。\n六、总结与决策树 核心结论 GIL 只影响 Python 字节码执行，不影响 C/C++ 层的计算（NumPy、PyTorch 算子、IO 等） 多线程对 CPU 密集型纯 Python 代码几乎无效；对 IO 密集型任务有效 向量化是第一优先级：既能加速单线程，又能释放 GIL，是深度学习和量化金融场景下的最优解 multiprocessing / joblib 是\u0026quot;不改算法\u0026quot;前提下最简单的 CPU 密集型加速手段 asyncio 是 IO 密集型任务的最优并发方案 决策树 你的瓶颈是什么？ │ ├── IO 密集型（网络请求、文件读写、数据库查询） │ └── 首选 asyncio（轻量、无竞态），其次 threading │ └── CPU 密集型 │ ├── 能改写为向量化运算？（NumPy/Pandas/PyTorch） │ └── YES → 向量化（首选，速度最快，代码最简洁） │ └── 不能向量化（逻辑复杂、依赖前序状态） │ ├── 任务粒度 \u0026gt; ~100ms？ │ └── YES → multiprocessing / joblib │ └── 任务粒度很小 → 考虑批量合并后再并行化 各场景推荐方案速查 场景 推荐方案 备注 Pandas 数据清洗 向量化（避免 .apply + lambda） .apply 是隐藏的 Python 循环 机器学习特征工程 NumPy 广播 / Pandas rolling 彻底消除 Python for 循环 DL 训练数据加载 DataLoader num_workers ≥ 2（需磁盘IO） workers 是进程，独立 GIL；纯内存数据集不适用 DL 大规模特征提取 NumPy 向量化预处理 + batch 推理 50K样本 14x vs 串行；消除 Python 循环是关键 量化因子计算 NumPy 矩阵化 / joblib 500 支股票 → 500×252 矩阵，28x 加速 蒙特卡洛定价（欧式） NumPy 向量化终价公式 GBM 解析解，~3430x 加速 向量化回测 Pandas rolling + ffill 信号 vs 事件驱动串行：20x 加速 大量独立 HTTP 请求 asyncio + aiohttp 万级并发无压力 ","permalink":"https://coldeye2020.github.io/tech/2026-04-19-python-gil/","summary":"\u003cp\u003e写 Python 写了几年，你或许曾遇到过这样的困惑：明明给数据处理加了多线程，CPU 占用率飙上去了，速度却几乎没变。这不是你的代码有 bug，而是 GIL 在起作用。\u003c/p\u003e\n\u003cp\u003e本文从原理出发，用实验数据说话，帮你彻底搞清楚 GIL 的边界——以及在机器学习、深度学习、量化金融场景下如何系统性地绕开它。\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e\u003cstrong\u003e实验环境\u003c/strong\u003e：Apple M3 Pro，Python 3.11.13，numpy 2.2.3，pandas 2.3.3，torch 2.11.0\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e实验代码\u003c/strong\u003e：https://github.com/Coldeye2020/gil_experiments\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003chr\u003e\n\u003ch2 id=\"一gil-是什么为什么存在\"\u003e一、GIL 是什么，为什么存在？\u003c/h2\u003e\n\u003cp\u003e\u003cstrong\u003e一句话定义\u003c/strong\u003e：GIL（Global Interpreter Lock，全局解释器锁）是 CPython 中的一把互斥锁，保证同一时刻只有一个线程在执行 Python 字节码。\u003c/p\u003e\n\u003ch3 id=\"从引用计数说起\"\u003e从引用计数说起\u003c/h3\u003e\n\u003cp\u003eCPython 用\u003cstrong\u003e引用计数\u003c/strong\u003e（reference counting）管理内存。每个 Python 对象都维护一个 \u003ccode\u003eob_refcnt\u003c/code\u003e 字段，记录当前有多少个引用指向它；当计数归零时，对象被立即释放。\u003c/p\u003e\n\u003cp\u003e这个机制简洁高效，但存在一个问题：如果多个线程同时对同一个对象的引用计数做 \u003ccode\u003e+1\u003c/code\u003e 或 \u003ccode\u003e-1\u003c/code\u003e，就会产生竞态条件（race condition）——两个线程同时读到旧值 \u003ccode\u003en\u003c/code\u003e，各自写回 \u003ccode\u003en+1\u003c/code\u003e，结果只加了一次而不是两次。这会导致内存泄漏，甚至在错误时机释放仍在使用的对象，引发崩溃。\u003c/p\u003e\n\u003cp\u003e理论上可以给每个对象的引用计数加一把细粒度锁，但这意味着几乎每次对象访问都要加锁解锁，开销极大。CPython 的设计者 Guido van Rossum 在 1990 年代初选择了一个更简单的方案：加一把大锁，锁住整个解释器。这把锁就是 GIL。\u003c/p\u003e\n\u003ch3 id=\"用代码验证引用计数\"\u003e用代码验证引用计数\u003c/h3\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-python\" data-lang=\"python\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"kn\"\u003eimport\u003c/span\u003e \u003cspan class=\"nn\"\u003esys\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"n\"\u003ea\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"p\"\u003e[]\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nb\"\u003eprint\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003esys\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003egetrefcount\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003ea\u003c/span\u003e\u003cspan class=\"p\"\u003e))\u003c/span\u003e   \u003cspan class=\"c1\"\u003e# 输出 2：a 本身持有 1 个引用，getrefcount 调用时临时持有 1 个\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"n\"\u003eb\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003ea\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nb\"\u003eprint\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003esys\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003egetrefcount\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003ea\u003c/span\u003e\u003cspan class=\"p\"\u003e))\u003c/span\u003e   \u003cspan class=\"c1\"\u003e# 输出 3：b 也引用了同一个列表\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003edel\u003c/span\u003e \u003cspan class=\"n\"\u003eb\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nb\"\u003eprint\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003esys\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003egetrefcount\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003ea\u003c/span\u003e\u003cspan class=\"p\"\u003e))\u003c/span\u003e   \u003cspan class=\"c1\"\u003e# 输出 2：b 被删除，引用数减 1\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u003ccode\u003esys.getrefcount()\u003c/code\u003e 让我们直接观察引用计数的变化。正是这个机制的存在，让 GIL 成为 CPython 的\u0026quot;必要之恶\u0026quot;。\u003c/p\u003e","title":"深入理解 Python GIL：从原理到实践"},{"content":" 论文：QuantaAlpha: An Evolutionary Framework for LLM-Driven Alpha Mining\nGitHub：QuantaAlpha/QuantaAlpha\n如果对QuantAlpha的代码感兴趣，可以试试配套互动代码教程。\n如果你听说过\u0026quot;量化基金\u0026quot;\u0026ldquo;AI 炒股\u0026quot;这类词，可能会好奇：AI 究竟是怎么在股市里找到赚钱机会的？它找到的方法可靠吗？能持续有效吗？\n这篇文章想借一篇 2026 年的论文——QuantaAlpha——来回答这些问题。这篇论文做了一件有意思的事：不只是让 AI 去预测股价，而是让 AI 像一个量化研究员一样自己想出预测方法，然后不断进化改进。\n我会从量化研究是什么讲起，一路讲到这篇论文的核心机制和实验结果。涉及公式的地方会附上直觉解释，不需要数学背景也能理解。\n量化研究在做什么？ 从\u0026quot;选股\u0026quot;说起 普通投资者选股，靠的可能是新闻、财报、直觉。量化研究员则不同——他们的工作是找到可以被数学描述、可以被计算机执行的选股规律。\n这类规律叫做因子（factor）。一个因子本质上是一条规则：给市场上所有股票打一个分，然后买高分股票、卖低分股票，看这样做长期下来能不能赚钱。\n最简单的因子：过去一个月涨幅最大的股票，下个月继续跑赢的概率更高（动量因子）。这条规律在很多市场里真的存在，背后有经济学解释（机构资金买入需要时间，趋势会延续），也有大量实证数据支持。\n因子本身通常是一个数学表达式，作用在价格、成交量等原始数据上，输出一个分数。比如：\n$$f_t(i) = \\frac{P_t(i) - P_{t-20}(i)}{P_{t-20}(i)}$$这就是一个 20 日动量因子——用今天的价格除以 20 天前的价格再减一，得到这 20 天的涨跌幅，作为对股票 $i$ 的打分。\n\u0026ldquo;Alpha\u0026rdquo;是量化圈里的另一个核心词。它指的是扣除市场整体涨跌之后，策略额外赚到的收益（超额收益）。如果大盘涨了 10%，你的策略涨了 16%，那多出来的 6% 就是 Alpha。挖掘 Alpha，就是寻找能持续产生超额收益的因子。\n量化因子挖掘的传统流程 在大语言模型（LLM）出现之前，量化研究员找因子的方式主要有两种：\n人工驱动：研究员根据金融理论提出假设，手动设计数学表达式，回测验证。速度慢，但每个因子背后有清晰的经济学逻辑。\n机器搜索：用遗传算法或强化学习在因子空间里暴力搜索，速度快，但搜出来的因子往往是无法解释的\u0026quot;天书\u0026quot;公式。\nLLM 的出现提供了第三条路：它既理解金融语义，又能生成代码，理论上可以结合两者的优点。\n现有 LLM Agent 方法是怎么做的？ 大多数现有的 LLM Agent 因子挖掘方法，流程大致如下：\n通用 LLM Agent 方法 ───────────────────────────────────────── 市场数据 ──→ [LLM] 提出假设 ──→ 生成因子代码 ↑ │ │ ↓ 修改假设 ←──── 回测评估（IC等） │ ↓ 搜索空间逐渐收缩 （反复修改，越来越像） ───────────────────────────────────────── 这个流程有一个隐藏的问题：LLM 每一轮都根据上一轮的回测结果\u0026quot;打补丁\u0026rdquo;，慢慢地，所有的修改方向都指向同一片区域，搜索空间在不断收缩。结果是生成了大量长相相似的因子，整体多样性越来越差。\nQuantaAlpha 的流程设计与此截然不同：\nQuantaAlpha ───────────────────────────────────────── 多样化规划：同时生成 10 个互补方向 ↓ [10 条轨迹各自独立运行] ↓ 假设生成（Idea Agent）← 评估历史库反馈 ↓ 语义一致性检查 形式化描述 ↓ 语义一致性检查 符号表达式（AST）──→ 复杂度检查 / 冗余过滤 ↓ 可执行代码 ↓ 回测评估（Evaluation Agent） ↓ 轨迹存入轨迹池 ──→ 评估历史库（每轮结束后更新） ↓ ┌─ Mutation：定位最差步骤，冻结前缀，重写后续 │ ↓（新轨迹入池） └─ Crossover：跨轨迹合并高贡献片段 ↓（新轨迹入池，重复 5 轮，搜索空间持续扩张） 因子池筛选（Rank IC 降序，相关系数 \u0026lt; 0.7） ↓ LightGBM → 策略 ───────────────────────────────────────── 下图展示了三代方法的对比，核心区别一目了然：现有 Agent 方法的搜索空间越来越窄，QuantaAlpha 的搜索空间持续扩展：\n三代方法对比：ML 黑箱（左）、现有 LLM Agent 搜索空间收缩（中）、QuantaAlpha 搜索空间持续扩展（右） 最核心的差异有两点：质量约束在生成时就执行（而非最后过滤），以及在轨迹层面做进化（而非只优化单个因子）。下面我们逐一展开。\n股票市场为什么难预测？ 在介绍论文方法之前，先搞清楚为什么这件事本来就很难。论文开篇点明了金融市场的三个本质特性：\n重尾分布：极端事件比你想象的多得多 我们日常生活中的很多现象服从正态分布（Normal Distribution，也叫钟形曲线）。比如人的身高，大多数人集中在均值附近（170cm 左右），极端矮或极端高的人很少。\n$$p(x) = \\frac{1}{\\sigma\\sqrt{2\\pi}} \\exp\\left(-\\frac{(x-\\mu)^2}{2\\sigma^2}\\right)$$股票的日收益率看起来也像正态分布，但如果你仔细看尾部，会发现股市的极端涨跌出现的频率，远高于正态分布的预测。这种现象叫做重尾分布（Fat-Tailed / Heavy-Tailed Distribution）。\n下面是正态分布和重尾分布的示意对比：\n概率密度 ↑ │ 正态分布：中间高，两边快速归零 │ ████ │ ██████ │ ████████ │ ████████████ │ ████████████████ ├──────────────────── 收益率 -3σ -σ 0 σ 3σ 概率密度 ↑ │ 重尾分布：中间更尖，但两边下降更慢 │ ██ │ ████ │ ██████ │ ██████████ │ ▓▓▓████████████████▓▓▓ ← 尾部比正态分布\u0026#34;厚\u0026#34; ├──────────────────────── 收益率 -3σ -σ 0 σ 3σ 用数字来感受一下：在正态分布下，单日涨跌超过 3 个标准差的概率约为 0.27%，平均每 370 个交易日才出现一次。但在实际股市中，这种\u0026quot;3 倍标准差\u0026quot;的事件每隔几十天就可能出现一次——比理论预测高出数十倍。\n对量化研究的影响是：\n回测结果充满噪声，某个因子今天看起来很好，可能只是碰上了一次极端行情 基于正态分布假设的统计方法（比如普通线性回归）会严重低估风险 因子有效性的判断需要更长的时间周期才能稳定 这也是为什么论文要用 Rank IC（用排名代替原始数值）而不只是普通 IC——排名操作把极端值压制下来，对重尾分布更鲁棒。\n时变波动率：规律本身会变 不仅股票收益率的均值难以预测，就连波动的剧烈程度也在不断变化。平静期里，每天涨跌 0.5%；动荡期里，每天涨跌 3-5%。因子信号在这两种环境下的\u0026quot;有效期\u0026quot;完全不同，一个在低波动环境里有效的因子，搬到高波动环境可能完全失灵。\n截面依赖：股票之间相互影响 同一时刻，市场上的 500 只股票不是独立行动的。行业联动、宏观消息、资金流向——这些因素会让相关行业的股票同涨同跌。如果你的因子忽略了这种相关性，就会高估持有多只股票的分散化效果，实际承担的风险比你以为的大得多。\nQuantaAlpha 的框架 论文把整个系统设计为四个模块，像流水线一样串联：\nQuantaAlpha 四模块流水线总览：多样化规划 → 因子构建 → 三重约束门 → 自进化 第一步：多样化规划——从 10 个方向出发 系统首先生成 10 个互补的初始研究方向，覆盖不同的信号来源和逻辑类型：\n维度 两端 信号来源 价格类 ↔ 成交量类 时间尺度 短期（1-5日）↔ 长期（20-60日） 机制类型 动量 / 均值回归 / 波动率结构 / 隔夜信息 表0：多样化规划强制覆盖的三个正交维度\n为什么要强制多样化？因为 LLM 有\u0026quot;偷懒\u0026quot;的倾向——你让它想 10 个假设，它可能给你同一个想法的 10 种说法。显式的多样性约束能逼迫它真正覆盖不同方向，为后续进化提供广阔的起点。\n第二步：因子构建——从想法到代码的四层翻译 每个假设由三个 AI 角色协作完成：\n想法 AI 提出带有经济学逻辑的假设，比如\u0026quot;机构投资者在收盘前集中建仓，导致收盘价与开盘价的差距能预测第二天的交易量变化\u0026quot;。\n构建 AI 把假设变成可运行的因子公式，经过四层翻译：\n假设（自然语言） ↓ 形式化描述（用哪些数据特征、哪类操作、参数怎么设） ↓ 数学表达式（像乐高积木一样拼装起来的符号公式） ↓ 可执行代码（可以直接跑的 Python/Qlib 代码） 每层之间有语义一致性检查——如果假设说的是\u0026quot;动量效应\u0026quot;，最终的公式却变成了\u0026quot;均值回归\u0026quot;，系统会发现这个矛盾并要求修改，而不是放任这种偷偷走样的情况发生。\n评估 AI 负责回测并记录结果，同时维护一份\u0026quot;成功和失败因子的规律库\u0026quot;，反馈给想法 AI，让下一轮假设更有针对性。\n一个具体例子：从假设到代码的四步翻译\n假设 Idea Agent 提出了这样一个假设：\n\u0026ldquo;机构资金在收盘前集中买入，导致收盘价相对开盘价偏高；同时，成交量同步放大说明资金参与真实。这种\u0026rsquo;价涨量增\u0026rsquo;的组合能预测次日的延续性动量。\u0026rdquo;\nFactor Agent 接着完成四步翻译：\n第一步：形式化描述（把假设翻译成\u0026quot;用什么数据、做什么操作\u0026quot;）\n特征：close（收盘价）、open（开盘价）、volume（成交量） 操作：计算日内收益（close-open）/close；计算成交量变化率 Δvol/vol；对两者取 20 日滑动相关系数；截面排名\n第二步：符号表达式（把描述翻译成数学公式）\n$$f = \\text{RANK}\\Big(\\text{TS\\_CORR}\\Big(\\frac{\\text{close}-\\text{open}}{\\text{close}},\\ \\frac{\\Delta\\text{vol}}{\\text{vol}},\\ 20\\Big)\\Big)$$20 日价量相关性高，说明\u0026quot;涨的时候成交量也大\u0026quot;——正是机构资金参与的特征。截面排名把所有股票拉到同一标尺上比较。\n第三步：语义一致性检查\n系统检查：假设说的是\u0026quot;价涨量增\u0026quot;，表达式用了价量相关性——语义对齐，通过。\n第四步：生成可执行代码\ndef factor(close, open_, volume): intraday_ret = (close - open_) / close vol_chg = volume.pct_change() corr = intraday_ret.rolling(20).corr(vol_chg) return corr.rank(axis=1, pct=True) 代码和表达式语义再次核验：rolling(20).corr 对应 TS_CORR(\u0026hellip;, 20)，rank(pct=True) 对应 RANK，通过。\n整个过程每一步都有\u0026quot;锚点\u0026quot;，LLM 不会悄悄把\u0026quot;动量因子\u0026quot;改成\u0026quot;均值回归因子\u0026quot;而没人发现。\n第三步：三重约束门——质量管控在生成时就执行 这是 QuantaAlpha 与通用 Agent 方法最重要的设计差异之一。\n传统方法是在最后过滤掉不合格因子。问题在于：AI 根本不知道有这些标准，会反复生成同类问题的因子，计算资源白白浪费。而且 AI 的优化目标（预测准确）和约束条件（低换手率、低复杂度）之间存在矛盾，事后过滤无法解决这个矛盾，只会让搜索方向反复跑偏。\nQuantaAlpha 的做法是把三道约束门嵌入生成过程，因子一生成就检查，不通过则当场修改：\n第一道：语义一致性。检查假设 $h$ 和描述 $d$ 之间、符号表达式 $f$ 和代码 $c$ 之间有没有语义漂移。不一致则只修改不一致的那一层，其余已验证的内容保留。\n第二道：复杂度控制。论文用一个加权公式量化因子的复杂程度：\n$$\\mathcal{C}(f) = \\alpha_1 \\cdot SL(f) + \\alpha_2 \\cdot PC(f) + \\alpha_3 \\cdot \\log(1 + |F_f|)$$三项分别控制：\n$SL(f)$（Symbol Length）：因子公式的长度，即数学表达式里的节点个数，上限 250 $PC(f)$（Parameter Count）：自由参数数量（比如\u0026quot;20 日窗口\u0026quot;里的\u0026quot;20\u0026quot;），自由参数占比不超过 50% $|F_f|$（Feature Count）：用到的原始数据特征数量，上限 6 个 这道关卡的消融实验结果最为显著：去掉复杂度控制后，年化超额收益下降 8.44%，最大回撤上升 2.57%——典型的过拟合症状：训练期把历史噪声\u0026quot;背\u0026quot;下来了，测试期一遇到新环境就崩。\n第三道：冗余过滤。通过比较两个因子的数学结构相似度，过滤掉和已有因子高度重复的新因子：\n$$s(f_i, f_j) = \\max_{\\substack{S_i \\subseteq T(f_i),\\; S_j \\subseteq T(f_j) \\\\ S_i \\cong S_j}} |V(S_i)|, \\quad S(f) = \\max_{\\phi \\in \\mathcal{Z}}\\, s(f, \\phi)$$这里比较的是因子公式的\u0026quot;骨架结构\u0026quot;，术语叫抽象语法树（AST，Abstract Syntax Tree）。\n什么是 AST？\n任何数学公式或代码，都可以被解析成一棵树形结构——每个操作是一个节点，操作数是它的子节点。比如公式 RANK(TS_MEAN(close, 5)) 对应的树是：\nRANK │ TS_MEAN / \\ close 5 再复杂一点，RANK(TS_CORR(close, volume, 20) * TS_MEAN(close, 5)) 对应：\nRANK │ MUL / \\ TS_CORR TS_MEAN / | \\ / \\ close vol 20 close 5 这棵树就是因子的\u0026quot;骨架\u0026quot;——不管用哪只股票的数据算，骨架是固定的。\n冗余检测是怎么做的？\n把两个因子的 AST 都画出来，然后找\u0026quot;最大的公共子树\u0026quot;——也就是两棵树里结构完全相同的最大片段。\n举个例子。假设因子库里已有因子 A：\n$$f_A = \\text{RANK}(\\text{TS\\_CORR}(\\text{close}, \\text{volume}, 20))$$现在新生成了因子 B：\n$$f_B = \\text{RANK}(\\text{TS\\_CORR}(\\text{close}, \\text{volume}, 20) \\times \\text{TS\\_STD}(\\text{close}, 5))$$两者的 AST 对比：\n因子 A 因子 B RANK RANK │ │ TS_CORR MUL / | \\ / \\ close vol 20 TS_CORR TS_STD / | \\ / \\ close vol 20 close 5 最大公共子树是 TS_CORR(close, vol, 20)，包含 4 个节点。如果这个数字超过阈值（论文设为 5），系统就认为因子 B 与 A 高度重叠，直接拒绝。\n为什么不用数值相关系数来判断冗余？因为两个因子的数值相关性需要先跑完回测才能算，代价很高；而 AST 比对在生成阶段就能完成，几乎没有计算成本。而且结构冗余比数值冗余更能反映\u0026quot;同质化\u0026quot;——两个结构相似的因子，即使在某段时间数值相关性不高，在新市场上也很可能同时失效。\n消融结果：去掉冗余过滤后，ARR 几乎不变（-0.62%）。论文指出多样性是泛化能力而非预测力的来源——因子冗余会降低跨市场迁移时的鲁棒性，但不直接影响样本内预测准确度。\n下面是约束门消融的完整结果：\n三重约束门消融实验结果（CSI300） 表1：三重约束门消融结果（CSI300，括号内为与完整模型的差值）\n消融条件 IC ARR(%) MDD(%) 完整模型 0.149 28.99 9.42 去掉语义一致性 0.130（-0.019） 23.51（-5.48） 9.75 去掉复杂度控制 0.125（-0.024） 20.55（-8.44） 11.99（+2.57） 去掉冗余过滤 0.122（-0.027） 28.37（-0.62） 9.44 三道全去掉 0.122（-0.027） 17.55（-11.44） 10.69 第四步：自进化——Mutation 和 Crossover 通过三道关的因子进入轨迹池，接下来进行核心的进化操作。\n什么是\u0026quot;轨迹\u0026quot;？\n一条轨迹 (trajectory) 是一次完整的 alpha 挖掘工作记录：从最初的假设，到每一步的因子生成和修改，到最终的回测结果，全程都保存下来。就像一个研究员从灵感到结论的完整笔记——可以回头翻阅，可以找到哪一步写错了，也可以把两份笔记里最精彩的部分摘出来组合成新的研究。\n论文把优化目标形式化为：\n$$\\pi^* = \\arg\\max_{\\pi} \\mathbb{E}_{\\tau \\sim \\pi}[R(\\tau)], \\quad R(\\tau) = \\mathcal{L}(f_\\tau(\\mathbf{X}), \\mathbf{y}) - \\lambda\\mathcal{R}(f_\\tau)$$$\\tau$ 就是一条轨迹，$R(\\tau)$ 是这条轨迹的奖励。奖励同时包含预测准确性 $\\mathcal{L}$（IC、ICIR 等）和正则项 $\\mathcal{R}$（复杂度 + 因子间相关性），$\\lambda$ 控制二者的权衡。\n变异（Mutation）：找到最弱的一步，精准修复\n$$\\tau_{\\text{child}} = (s_0, a_0, \\ldots, s_k, \\text{Refine}(a_k), s'_{k+1}, \\ldots, s'_n)$$AI 回顾一条轨迹，找出最拖累结果的步骤 $k$，冻结 $k$ 之前的所有内容（已验证的好内容不动），只重写第 $k$ 步及其后续。可以更换经济机制（动量→波动率结构）、调整时间窗口（5日→20日）、引入条件判断（\u0026ldquo;只在成交量上升时触发\u0026rdquo;）。\n为什么这是四个组件里消融效果最显著的（ARR -9.81%，IC -0.0292）？因为变异同时做了两件事：修复已知的错误，以及通过机制级别的修改跳出局部最优。\n交叉（Crossover）：从多条成功轨迹里提取精华\n$$\\tau_{\\text{child}} = \\text{Crossover}(\\tau^{(1)}, \\ldots, \\tau^{(k)})$$从上一轮效果最好的几条轨迹里，各自提取贡献最大的片段（某条轨迹的假设特别好、另一条的因子结构特别巧妙），组合成新的轨迹。\n这模拟的是研究员之间的协作：研究员甲擅长捕捉隔夜跳空信息，研究员乙擅长建模波动率结构，两人合作能产生\u0026quot;用隔夜信息触发波动率结构判断\u0026quot;的新思路。\n额外价值：每个因子都有可追溯的来源谱系——假设从哪条轨迹来，结构参考了哪个父轨迹，全程可审计。这是解决\u0026quot;AI 黑盒\u0026quot;问题的一个具体手段。\n一个因子的完整成长史 论文追踪了因子 Institutional_Momentum_Score_20D（机构驱动 20 日动量评分）从第 1 轮到第 5 轮的完整进化过程，可以清晰看到整套机制是如何运作的：\n`Institutional_Momentum_Score_20D` 五轮进化轨迹，ARR 从 13.27% 提升至 29.63%。Y 轴为因子池整体累计超额收益，括号内 ARR/MDD 为单条轨迹回测指标。 Iter 1（初始化）：假设是\u0026quot;低成交量 = 市场噪声少，此时做均值回归效率更高\u0026quot;。生成因子 RegimeFiltered_Reversal_5D。ARR=13.27%，MDD=7.67%。回测反馈：缺少幅度信息和风险控制。\nIter 2（变异）：定位问题——缺少方向确认。修改为：把 1 日、5 日、20 日三个时间尺度的动量信号对齐，再加上波动率门控。ARR 仅 7.35%，但 MDD 暴涨到 18.7%——过度工程化，过拟合了。\nIter 3（交叉）：从其他轨迹里借来更简洁的结构，大幅简化，用标准的 5日/20日波动率比率加动量，线性加权替代复杂嵌套条件。ARR 提升到 22.38%，MDD 大幅回落。第 2 轮复杂化、第 3 轮简化的来回，正是交叉纠正过拟合的直观演示。\nIter 4（变异）：在简洁结构基础上，融合波动率机制与更精准的嵌套动量。ARR=27.85%。\nIter 5（交叉）：整合两条父轨迹的精华——父轨迹 1 擅长识别\u0026quot;零售投机型脆弱动量\u0026quot;，父轨迹 2 擅长识别\u0026quot;机构支撑的可持续动量\u0026quot;。最终因子：\n$$f = \\text{RANK}\\Big(\\underbrace{\\text{TS\\_CORR}\\big(\\tfrac{\\Delta\\text{close}}{\\text{close}},\\, \\tfrac{\\Delta\\text{vol}}{\\text{vol}},\\, 20\\big)}_{\\text{20日价量相关性}} \\times \\underbrace{\\text{TS\\_MEAN}\\big(\\tfrac{\\text{close}-\\text{open}}{\\text{close}},\\, 5\\big)}_{\\text{5日平均日内收益}}\\Big)$$经济含义：价量同步变动说明机构资金真正参与（而非散户跟风），这种情况下的持续上涨才是可信的动量。ARR=29.63%，每个组件都有清晰的经济学解释，来源可追溯。\n实验 一篇量化方法论文，需要回答四个核心问题：\n整体效果：比现有方法强多少？ 各组件贡献：哪些设计真正有效，哪些是多余的？ 泛化能力：在没见过的市场上能用吗？ 收敛性：跑多少轮最合适，会不会越跑越差？ 实验设置对应这四个问题：三个市场（CSI300/CSI500/S\u0026amp;P500），训练期 2016-2020，验证期 2021，测试期 2022-2025。测试期故意覆盖了 2023 年的 A 股风格切换，这是对因子鲁棒性的严苛考验。\n评估指标 在看数字之前，我们需要理解这些指标的含义和计算方式。\n所有策略指标的基础是超额收益：\n$$r_{\\text{excess},t} = r_{\\text{portfolio},t} - r_{\\text{benchmark},t} - c_{\\text{transaction},t}$$买入手续费 0.05%，卖出手续费 0.15%（扣掉这个之后，那些靠频繁买卖刷 IC 的策略就原形毕露了）。\nIC（Information Coefficient，信息系数）：衡量因子打分和股票实际涨跌幅的一致程度。\n$$\\text{IC}_t = \\frac{(\\mathbf{f}_t - \\bar{f}_t\\mathbf{1})^\\top(\\mathbf{r}_{t+1} - \\bar{r}_{t+1}\\mathbf{1})}{\\|\\mathbf{f}_t - \\bar{f}_t\\mathbf{1}\\|_2 \\cdot \\|\\mathbf{r}_{t+1} - \\bar{r}_{t+1}\\mathbf{1}\\|_2}$$本质是因子值向量和次日收益率向量的 Pearson 相关系数，每天算一个，最后取平均。IC \u0026gt; 0.03 认为有实用价值，\u0026gt; 0.05 相当不错。绝大多数机构量化因子 IC 在 0.03~0.06 之间，QuantaAlpha 达到 0.1501 属于顶级水平。\n举个例子：某天对 300 只股票打分，分数最高的 10 只，平均第二天涨了 0.8%；分数最低的 10 只，平均跌了 0.3%。这个\u0026quot;高分股票涨、低分股票跌\u0026quot;的分离程度，就是 IC 在衡量的东西。\nICIR（IC Information Ratio）：IC 的稳定性。\n$$\\text{ICIR} = \\overline{\\text{IC}} / \\sigma(\\text{IC})$$只看平均 IC 不够——一个因子可能某几个月 IC 极高，其余月份接近 0，平均值看起来不错，但实盘完全没法用。ICIR 把均值除以标准差，高 ICIR 意味着这个因子每个月都比较稳定地有效。ICIR \u0026gt; 0.5 认为稳定，\u0026gt; 1.0 属于非常优秀。QuantaAlpha 达到 0.9110。\nRank IC（排名信息系数）：对重尾分布的鲁棒版 IC。\n$$\\text{RankIC}_t = \\text{Pearson}\\big(\\text{rank}(\\mathbf{f}_t),\\ \\text{rank}(\\mathbf{r}_{t+1})\\big)$$把因子值和收益率都换成各自的排名，再算相关系数。这样做的好处：某只股票当天暴涨 30%（极端值），在普通 IC 里会对结果产生巨大影响；换成排名之后，它只是\u0026quot;排名第一\u0026quot;，和排名第二的股票差距被压缩，极端值的影响被大幅削弱。\nARR（Annualized Excess Return Rate，年化超额收益率）：策略每年平均跑赢大盘多少。这是最直观的盈利指标。\nMDD（Maximum Drawdown，最大回撤）：从策略净值的历史峰值，到之后某个谷底，超额收益累计下跌的最大幅度。衡量的是\u0026quot;赚钱过程中最痛苦的那段时间有多痛\u0026quot;，直接影响实盘能不能坚持下去。\nIR（Information Ratio，信息比率）：\n$$\\text{IR} = \\frac{\\bar{r}_{\\text{excess}}}{\\sigma(r_{\\text{excess}})} \\times \\sqrt{252}$$年化超额收益除以超额收益的波动率。衡量\u0026quot;每承担一单位风险，能换来多少超额收益\u0026quot;，是衡量策略质量的综合指标。IR \u0026gt; 1.0 在业界被认为是优秀策略的门槛。\nIR 与夏普比率（Sharpe Ratio）的区别在于分母：夏普比率除以的是组合总收益的波动率（含市场 beta），IR 除以的是超额收益（相对 benchmark）的波动率。量化选股策略通常用 IR 而非夏普，因为 IR 剔除了市场整体涨跌的影响，更纯粹地衡量选股能力。\nCR（Calmar Ratio，卡尔玛比率）：\n$$\\text{CR} = \\text{ARR} / |\\text{MDD}|$$年化收益率除以最大回撤。衡量\u0026quot;每忍受一单位的最大痛苦，能换来多少年化收益\u0026quot;，对最大回撤有心理承受要求的投资者特别看重这个指标。\n这些指标为什么要同时看？ 因为它们互相制约，任何一个单独好看都可能是\u0026quot;作弊\u0026quot;：\n作弊方式 被哪个指标识破 高频交易刷高表面收益 扣手续费后 ARR 崩塌 激进加仓博短期高收益 MDD 暴露极端风险 靠运气赚几次大的 ICIR 显示收益不稳定 只在牛市有效 超额收益相对 benchmark，牛市 alpha 被剥离 主要实验结果 表2：各方法在 CSI300 测试期（2022-2025）的对比，使用各自最优 backbone\n方法 IC ARR (%) MDD (%) TRA（最佳深度学习） 0.0421 6.81 8.51 经典因子库最优 — 4.63 — RD-Agent（GPT-5.2） 0.0531 9.91 14.82 AlphaAgent（Claude-4.5-Sonnet） 0.1092 16.48 8.14 QuantaAlpha（GPT-5.2） 0.1501 27.75 7.98 两个关键对比值得细看：\n同为 GPT-5.2，QuantaAlpha vs RD-Agent：IC 提升 183%，ARR 提升 17.84 个百分点，MDD 从 14.82% 降到 7.98%。差距几乎全来自约束机制——没有生成阶段质量管控的 RD-Agent，因子会越来越复杂越来越过拟合，实盘波动大、回撤深。\nQuantaAlpha vs AlphaAgent：AlphaAgent 已有生成阶段正则化，所以优于 RD-Agent，但缺少轨迹级进化。ARR 差了 12.21 个百分点，MDD 差了 4.91 个百分点——这个差距量化的就是 Mutation + Crossover 的贡献。\nQuantaAlpha 在全部五个 backbone LLM（Qwen3-235B、DeepSeek-V3.2、Gemini-3-Pro、Claude-4.5-Sonnet、GPT-5.2）上均优于对应的 AlphaAgent 和 RD-Agent，说明提升来自框架本身，与用哪个 LLM 无关。\n进化组件各自贡献多少？ 表3：各进化组件消融结果（括号内为与完整模型的差值）\n消融条件 IC ARR(%) MDD(%) 完整模型 0.1493 28.99 9.42 去掉多样化规划 0.1488（-0.0005） 21.21（-7.78） 12.15（+2.73） 去掉变异（Mutation） 0.1201（-0.0292） 19.18（-9.81） 8.99 去掉交叉（Crossover） 0.1423（-0.0070） 26.17（-2.82） 10.63（+1.21） 三个观察：变异是最核心的组件，IC 和 ARR 降幅最大；多样化规划对 IC 影响极小，但对 MDD 影响最大（策略稳定性的主要来源）；交叉的贡献中等但稳定存在。\n迭代到第几轮最合适？ 随迭代轮次增加，ARR 先升后降，MDD 先降后升，第 11~12 轮（约 350 个因子）为最优区间，ARR 峰值 36.02%，MDD 最低 7.58% 最优区间在第 11~12 轮（约 350 个因子），此时 ARR 峰值 36.02%，MDD 最低 7.58%。第 12 轮之后，新因子开始引入冗余信息，策略稳健性下降。这给复现提供了一个实用的早停条件。\n2023 年 A 股风格切换：一次严苛的现实考试 测试期选在 2022-2025 年是有意为之，因为中间发生了一次 A 股的重大风格切换。\n切换之前（2023 年前）：大盘蓝筹股（茅台、宁德时代等\u0026quot;核心资产\u0026quot;）主导市场，机构持股比例高，日内价格变化平滑，动量信号持续性强，传统量价因子整体有效。\n切换触发因素（2023 年）：\n外资大规模净流出，核心资产估值体系重构 中特估（央企估值重估）政策驱动主题轮动 ChatGPT 引爆 AI 概念，A 股 AI 相关小盘股快速轮动，涨停板大量出现 百亿量化私募大量使用相似的高频量价因子，因子拥挤集中爆发 新的市场特征：隔夜跳空频繁（集合竞价信息极为重要），趋势持续性减弱，小盘主题快速轮动，板块内部快速分化。\n传统因子模型的两个隐含假设被同时打破：①日内价格动态是平滑的（隔夜跳空一来，前一天的动量信号开盘瞬间被推翻）；②趋势具有一定持续性（小盘主题轮动里，\u0026ldquo;均值\u0026quot;本身在漂移）。\n论文分析了 QuantaAlpha（QA）和 AlphaAgent（AA）在 2023 年全年的代表性因子表现，结果非常鲜明：\n表4：QuantaAlpha 2023 年表现最好的因子（集中在隔夜竞价信息和有条件趋势两类）\n因子名称 Rank IC IC 信号类型 GapZ10_Overnight_vs_TR 0.0793 0.0335 隔夜跳空 / 竞价信息 Gap_IntradayAcceptanceScore_20D 0.0744 0.0330 隔夜竞价接受度 Gap_IntradayAcceptance_VolWeighted_20D 0.0606 0.0314 成交量加权竞价信息 CleanTrend_Continuation_Score_RS10_WMA5 0.0590 0.0267 有条件的趋势延续 OrderlyTrend_x_Absorption_10D_5D_20D 0.0465 0.0271 有序趋势×筹码吸收 表5：QuantaAlpha 在 2023 年失效的因子（依赖\u0026quot;平滑日内价格\u0026quot;或\u0026quot;趋势持续\u0026quot;假设）\n因子名称 Rank IC IC KineticLength_AbsRetSum_Z_10D -0.0720 -0.0246 Drawdown_Gated_NegCorr_60D_28D_thr20pct -0.0282 -0.0095 HighClose_Shock_With_VolSync_60_20 -0.0274 -0.0090 表6：AlphaAgent 2023 年表现最好的因子（集中于耗竭型反转，Rank IC 量级明显偏低）\n因子名称 Rank IC IC Exhaustion_Intensity_Index_10D 0.0323 0.0159 Climax_Exhaustion_Intensity 0.0242 0.0160 Exhaustion_Volume_Instability_Index 0.0121 0.0117 表7：AlphaAgent 在 2023 年失效的因子（主要依赖流动性稳定假设）\n因子名称 Rank IC IC Relative_Volume_Calm_Reversal -0.0279 -0.0188 Volume_Stability_Momentum_Divergence_40D -0.0247 -0.0155 LVR_Bottom_Fishing_20D -0.0190 -0.0144 对比非常鲜明：QuantaAlpha 的强因子集中在隔夜竞价信息和有条件的趋势两类——前者在集合竞价主导的新市场环境里依然有效（Rank IC 0.0793），后者通过\u0026quot;流动性条件\u0026quot;过滤掉了小盘股的噪声趋势。AlphaAgent 的强因子是耗竭型反转（Rank IC 只有 0.03 量级），在快速轮动的小盘主题市场里远不如隔夜信息类因子稳健。\nQuantaAlpha 之所以能存活，根本原因是多样化初始化和冗余过滤共同保证了因子池覆盖多种不同的信号来源，总有一批因子与当前市场微结构对齐。\n零样本跨市场迁移 CSI300 上挖出的因子，不做任何修改，直接部署到 CSI500 和 S\u0026amp;P500。\nCSI300 因子直接迁移至 CSI500 和 S\u0026amp;P500 的累计超额收益，QuantaAlpha 在 2023 年底后持续领先，截至 2025 年末 CSI500 约 160%、S\u0026amp;P500 约 137% 2022-2023 年各方法差距不大，2023 年底后 QuantaAlpha 持续上扬，其余方法趋于平缓或微降。截至 2025 年末：\nCSI500：累计超额收益约 160% S\u0026amp;P500：累计超额收益约 137% 这是整篇论文最有力的泛化性证明：挖掘到的是金融市场普遍存在的规律（价量协同动量、隔夜信息等），而非 A 股大盘股特有的历史模式。\n设计哲学 论文最后把 QuantaAlpha 的设计提炼为三个核心属性，也是对\u0026quot;好的 AI 因子挖掘系统应该具备什么\u0026quot;的回答：\n多样性（Diversity）：广泛探索 + 冗余过滤，保证因子池覆盖多信息渠道。市场风格切换时不会全军覆没。\n可控性（Controllability）：AST 中间表示 + 生成时执行的三重约束，每一步都有语义锚点，不会偏离预设的经济学逻辑。\n可信度（Trustworthiness）：轨迹级继承让每个因子都有可追溯的谱系，AI 的工作过程可以被审计，而不是面对黑盒输出。\n这三个属性恰好对应论文开篇诊断的三个缺陷：因子拥挤 → 多样性，语义漂移 → 可控性，无法审计 → 可信度。问题和解法一一对应。\n现有局限：目前主要在股票资产上验证，商品/债券/外汇尚待探索；进化机制不显式感知当前市场 regime（牛熊/高低波动），未来可以让 Mutation 策略根据市场状态自适应切换；因子生成和组合构建目前相对独立，更紧密的整合理论上还有优化空间。\n但不管如何，让 AI 像有经验的研究员一样思考、记录、反省、协作，并保持完整的工作谱系——这个方向本身，已经相当值得期待。\n","permalink":"https://coldeye2020.github.io/invest/2026-04-11-quantaalpha-popular/","summary":"\u003cblockquote\u003e\n\u003cp\u003e论文：\u003ca href=\"https://arxiv.org/abs/2602.07085\"\u003eQuantaAlpha: An Evolutionary Framework for LLM-Driven Alpha Mining\u003c/a\u003e\u003c/p\u003e\n\u003cp\u003eGitHub：\u003ca href=\"https://github.com/QuantaAlpha/QuantaAlpha\"\u003eQuantaAlpha/QuantaAlpha\u003c/a\u003e\u003c/p\u003e\n\u003cp\u003e如果对QuantAlpha的代码感兴趣，可以试试配套\u003ca href=\"/course/quantaalpha/\"\u003e互动代码教程\u003c/a\u003e。\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003e如果你听说过\u0026quot;量化基金\u0026quot;\u0026ldquo;AI 炒股\u0026quot;这类词，可能会好奇：AI 究竟是怎么在股市里找到赚钱机会的？它找到的方法可靠吗？能持续有效吗？\u003c/p\u003e\n\u003cp\u003e这篇文章想借一篇 2026 年的论文——\u003cstrong\u003eQuantaAlpha\u003c/strong\u003e——来回答这些问题。这篇论文做了一件有意思的事：不只是让 AI 去预测股价，而是让 AI 像一个量化研究员一样\u003cstrong\u003e自己想出预测方法，然后不断进化改进\u003c/strong\u003e。\u003c/p\u003e\n\u003cp\u003e我会从量化研究是什么讲起，一路讲到这篇论文的核心机制和实验结果。涉及公式的地方会附上直觉解释，不需要数学背景也能理解。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"量化研究在做什么\"\u003e量化研究在做什么？\u003c/h2\u003e\n\u003ch3 id=\"从选股说起\"\u003e从\u0026quot;选股\u0026quot;说起\u003c/h3\u003e\n\u003cp\u003e普通投资者选股，靠的可能是新闻、财报、直觉。量化研究员则不同——他们的工作是找到\u003cstrong\u003e可以被数学描述、可以被计算机执行的选股规律\u003c/strong\u003e。\u003c/p\u003e\n\u003cp\u003e这类规律叫做\u003cstrong\u003e因子（factor）\u003c/strong\u003e。一个因子本质上是一条规则：给市场上所有股票打一个分，然后买高分股票、卖低分股票，看这样做长期下来能不能赚钱。\u003c/p\u003e\n\u003cp\u003e最简单的因子：过去一个月涨幅最大的股票，下个月继续跑赢的概率更高（动量因子）。这条规律在很多市场里真的存在，背后有经济学解释（机构资金买入需要时间，趋势会延续），也有大量实证数据支持。\u003c/p\u003e\n\u003cp\u003e因子本身通常是一个\u003cstrong\u003e数学表达式\u003c/strong\u003e，作用在价格、成交量等原始数据上，输出一个分数。比如：\u003c/p\u003e\n$$f_t(i) = \\frac{P_t(i) - P_{t-20}(i)}{P_{t-20}(i)}$$\u003cp\u003e这就是一个 20 日动量因子——用今天的价格除以 20 天前的价格再减一，得到这 20 天的涨跌幅，作为对股票 $i$ 的打分。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e\u0026ldquo;Alpha\u0026rdquo;\u003cstrong\u003e是量化圈里的另一个核心词。它指的是\u003c/strong\u003e扣除市场整体涨跌之后，策略额外赚到的收益\u003c/strong\u003e（超额收益）。如果大盘涨了 10%，你的策略涨了 16%，那多出来的 6% 就是 Alpha。挖掘 Alpha，就是寻找能持续产生超额收益的因子。\u003c/p\u003e\n\u003ch3 id=\"量化因子挖掘的传统流程\"\u003e量化因子挖掘的传统流程\u003c/h3\u003e\n\u003cp\u003e在大语言模型（LLM）出现之前，量化研究员找因子的方式主要有两种：\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e人工驱动\u003c/strong\u003e：研究员根据金融理论提出假设，手动设计数学表达式，回测验证。速度慢，但每个因子背后有清晰的经济学逻辑。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e机器搜索\u003c/strong\u003e：用遗传算法或强化学习在因子空间里暴力搜索，速度快，但搜出来的因子往往是无法解释的\u0026quot;天书\u0026quot;公式。\u003c/p\u003e\n\u003cp\u003eLLM 的出现提供了第三条路：它既理解金融语义，又能生成代码，理论上可以结合两者的优点。\u003c/p\u003e\n\u003ch3 id=\"现有-llm-agent-方法是怎么做的\"\u003e现有 LLM Agent 方法是怎么做的？\u003c/h3\u003e\n\u003cp\u003e大多数现有的 LLM Agent 因子挖掘方法，流程大致如下：\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003e  通用 LLM Agent 方法\n  ─────────────────────────────────────────\n\n  市场数据 ──→ [LLM] 提出假设 ──→ 生成因子代码\n                  ↑                     │\n                  │                     ↓\n              修改假设 ←──── 回测评估（IC等）\n                                        │\n                                        ↓\n                              搜索空间逐渐收缩 \n                         （反复修改，越来越像）\n  ─────────────────────────────────────────\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003e这个流程有一个隐藏的问题：LLM 每一轮都根据上一轮的回测结果\u0026quot;打补丁\u0026rdquo;，慢慢地，所有的修改方向都指向同一片区域，\u003cstrong\u003e搜索空间在不断收缩\u003c/strong\u003e。结果是生成了大量长相相似的因子，整体多样性越来越差。\u003c/p\u003e","title":"让 AI 自己学会挖掘股票信号？聊聊 QuantaAlpha 这篇论文"},{"content":"最近在看一个英雄联盟博主的视频，他讲了一个我觉得挺有意思的观点：真正能快速上分到高段位的玩家，往往不是英雄池宽广、什么都会的那种人，而是把一两个英雄练到极致的\u0026quot;绝活哥\u0026quot;。\n博主的解释是：如果你的英雄池很宽，你在每一局游戏里都要花注意力去熟悉技能怎么放、连招顺序是什么。这些操作层面的东西会把大脑塞满，让你根本没空去想那些更重要的事——对面打野在哪，这波兵线能不能推，团战应该怎么站。你不是不想想，是真的想不了，因为大脑已经满了。只有当一个英雄的操作被练到\u0026quot;根本不需要想\u0026quot;的程度，才能腾出空间去思考别的东西。\n记得之前国服长期排位霸榜第一的莎莉也说他自己的经历——曾经在白金段位卡了很久，直到专注练习豹女这一个英雄之后，突然开窍，从白金一路连胜到王者。\n我听完之后，结合一些自身的经历很有感触，把一些思考记录到了这篇博客中。\n高三的一段经历 这让我想起了高三备考时的一段经历。\n我高中三年英语一直不好，所以就默默接受了这个现实。有一次考试，完形填空20道题，我错了13个，这个数字把我自己都吓了一跳。当时刚好接触到了\u0026quot;刻意练习\u0026quot;的概念，就决定做个实验：先不管其他题型，只盯着完形填空一个题型死磕。每次拿到试卷，我都会带着最高的兴奋度去第一时间完成完形填空，似乎正常考试只有这一个题目是重要的，每次考试成绩出来也只会关注完形填空错了哪几道，犯了那些错误，其他题目是死是活我基本是完全不care了。做英语练习的时候也是类似的状态，只关注完形填空题型相关的错题。\n后面的结果出乎意料。我做完形填空错误数量从13个慢慢降到7-8个，再降到3-4个，最后基本维持在0-2个。与此同时，英语总分在一个月内从平均110分涨到了140分往上，从一开始英语倒数第一，到后面考进了前10。\n一开始我以为是刻意练习的功劳。但听完这个博主的解释之后，我开始重新想这件事——刻意练习可能只是形式，背后真正在起作用的，似乎是另一件事。\n做题的过程中，我能感受到一个很明显的变化。刚开始刷题时，脑子里什么都在想但什么也没想清楚。到了后期，做题的感觉越来越不一样——不是说变得更轻松了，而是思考的内容明显变少了，思考的方向变得明显清晰了，开始能感知到\u0026quot;这道题在考什么\u0026quot;、\u0026ldquo;我哪里比较薄弱\u0026rdquo;。\n这个变化让我觉得，可能不是我变聪明了，而是我主动降低了一些大脑负荷。\n工作记忆和认知负荷 带着这个疑问，我后来找到了一些相关的研究。\n认知心理学里有个概念叫工作记忆（Working Memory），可以理解成大脑里用来\u0026quot;当下处理信息\u0026quot;的临时空间。这块空间非常有限，研究表明人类工作记忆一次大约只能同时处理4到7个独立的信息块 1。\nSweller在1988年提出的认知负荷理论（Cognitive Load Theory）在此基础上进一步指出 2，认知资源是固定的总量，当低层次的操作占用了大量资源，留给真正学习的空间就少了。技能从生疏到熟练，心理学家Fitts和Posner描述了三个阶段 3：先是每个细节都要有意识地控制，极度消耗工作记忆；然后动作开始串联，有意识监控逐渐减少；最后达到自动化，执行几乎不再消耗工作记忆，意识可以去处理其他事情。\n用这个框架回头看，我高三的经历似乎说得通了。之前没有采用刻意刷题的方式前，我每切换一次题型，工作记忆被\u0026quot;这类题的做题技巧有哪些\u0026quot;、\u0026ldquo;回忆语法的考察方式\u0026quot;这些基础但是不重要的操作塞满，没有余量去感知更重要的知识。而控制刷题类型的好处在于做题技巧是固定的，同时每种语法在这些题型中的考察方式也类似，我可以真正只关注到语法使用本身，从而快速的提升自己的英语能力。\n当家教时的反面教材 这个理解让我想到自己当家教时犯的一个错误。\n因为自己的英语高效提升，我后来去当了两次英语家教，想把这套方法分享给学生。我让他们也专门刷完形填空，但每次做完题，我会把所有错误的知识点全部讲一遍——一道题做下来，发现可能有10个知识点需要补充，就全讲了。另外，我还喜欢做知识拓展，因为在我自己的经历里，把知识点串联起来对巩固很有帮助。\n但是效果很差。\n回头看，我当时大概是把自己的认知状态投射到了学生身上。对我来说，那10个知识点在脑子里可能已经是2-3个更大的模式；对基础薄弱的学生来说，这是10个毫无关联的全新信息，早就超出他们工作记忆的上限了。知识拓展对我有用，是因为我已经有了稳定的基础，新东西可以挂上去；对他们来说，多余的信息可能只是额外的负担。\nSweller的研究里有个叫专家反转效应（Expertise Reversal Effect）的现象 4：对专家有效的教学方式，对新手往往是有害的。专家太清楚知识之间的关联，反而感知不到基础薄弱者面对同样信息时的认知压力。\n更合适的做法，或许是只让他们关注那些最简单、最常见的错误，其余的暂时忽略，等在这一层建立了稳定的感觉，再引入下一层的复杂性。损失一些题目表面上的\u0026quot;利用率\u0026rdquo;，但认知负荷控制住了，进步可能反而会快一些。\n深度学习的联系 在找资料的过程中，我发现深度学习领域有一些有意思的研究，和这个思路不谋而合。\nBengio等人在2009年提出的课程学习（Curriculum Learning）发现，按照从易到难的顺序训练神经网络，模型收敛更快，泛化能力更强 5。另一项研究则发现，神经网络在训练过程中会优先拟合低频特征（全局结构、普遍规律），然后才逐渐拟合高频特征（局部细节、特殊情况） 6。换句话说，在训练初期，那些高频的细节信号对网络来说更像是噪声，不仅没有帮助，反而会干扰模型对基础规律的学习。\n这让我觉得，或许对于学习初期的人来说，过多的细节和拓展知识，并不像直觉上感觉的那样是\u0026quot;赚到了\u0026quot;，反而可能因为超出当前处理能力而变成噪声——被大脑直接过滤掉，什么也没留下，有时还会影响到真正有价值内容的学习。\n一边是人脑，一边是人工神经网络，两者在完全不同的背景下走向了类似的结论。我不确定这是否意味着什么更深层的东西，但这个巧合本身让我觉得挺有意思的。\n想到哪写到哪 我没有什么实践上的结论，只是把这些零碎的观察和思考写下来。\n如果这些想法有一个共同的方向的话，大概是：学习的瓶颈，可能不在于接收了多少信息，而在于认知资源有没有被集中在当前能够消化的那一层上。信息量超出处理能力，不会带来更快的进步，只会制造更多的混乱。\n就像现在信息爆炸的时代，我常会有一种信息不过脑的焦虑，现在想来可能就是因为现代社会过多的信息，对于还在和数万年前古人类用着同等型号大脑的现代人来说，实在是有一些认知负荷过载了。\nMiller, G. A. (1956). The magical number seven, plus or minus two: Some limits on our capacity for processing information. Psychological Review, 63(2), 81–97. Cowan, N. (2001). The magical number 4 in short-term memory. Behavioral and Brain Sciences, 24(1), 87–114.\u0026#160;\u0026#x21a9;\u0026#xfe0e;\nSweller, J. (1988). Cognitive load during problem solving: Effects on learning. Cognitive Science, 12(2), 257–285.\u0026#160;\u0026#x21a9;\u0026#xfe0e;\nFitts, P. M., \u0026amp; Posner, M. I. (1967). Human Performance. Brooks/Cole.\u0026#160;\u0026#x21a9;\u0026#xfe0e;\nKalyuga, S., Ayres, P., Chandler, P., \u0026amp; Sweller, J. (2003). The expertise reversal effect. Educational Psychologist, 38(1), 23–31.\u0026#160;\u0026#x21a9;\u0026#xfe0e;\nBengio, Y., Louradour, J., Collobert, R., \u0026amp; Weston, J. (2009). Curriculum learning. Proceedings of the 26th International Conference on Machine Learning (ICML).\u0026#160;\u0026#x21a9;\u0026#xfe0e;\nXu, Z. Q. J., Zhang, Y., Luo, T., Xiao, Y., \u0026amp; Ma, Z. (2019). Frequency principle: Fourier analysis sheds light on implicit regularization of gradient descent in neural networks. arXiv preprint arXiv:1901.06523.\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n","permalink":"https://coldeye2020.github.io/think/2026-04-11-cognitive-load/","summary":"\u003cp\u003e最近在看一个英雄联盟博主的视频，他讲了一个我觉得挺有意思的观点：真正能快速上分到高段位的玩家，往往不是英雄池宽广、什么都会的那种人，而是把一两个英雄练到极致的\u0026quot;绝活哥\u0026quot;。\u003c/p\u003e\n\u003cp\u003e博主的解释是：如果你的英雄池很宽，你在每一局游戏里都要花注意力去熟悉技能怎么放、连招顺序是什么。这些操作层面的东西会把大脑塞满，让你根本没空去想那些更重要的事——对面打野在哪，这波兵线能不能推，团战应该怎么站。你不是不想想，是真的想不了，因为大脑已经满了。只有当一个英雄的操作被练到\u0026quot;根本不需要想\u0026quot;的程度，才能腾出空间去思考别的东西。\u003c/p\u003e\n\u003cp\u003e记得之前国服长期排位霸榜第一的莎莉也说他自己的经历——曾经在白金段位卡了很久，直到专注练习豹女这一个英雄之后，突然开窍，从白金一路连胜到王者。\u003c/p\u003e\n\u003cp\u003e我听完之后，结合一些自身的经历很有感触，把一些思考记录到了这篇博客中。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"高三的一段经历\"\u003e高三的一段经历\u003c/h2\u003e\n\u003cp\u003e这让我想起了高三备考时的一段经历。\u003c/p\u003e\n\u003cp\u003e我高中三年英语一直不好，所以就默默接受了这个现实。有一次考试，完形填空20道题，我错了13个，这个数字把我自己都吓了一跳。当时刚好接触到了\u0026quot;刻意练习\u0026quot;的概念，就决定做个实验：先不管其他题型，只盯着完形填空一个题型死磕。每次拿到试卷，我都会带着最高的兴奋度去第一时间完成完形填空，似乎正常考试只有这一个题目是重要的，每次考试成绩出来也只会关注完形填空错了哪几道，犯了那些错误，其他题目是死是活我基本是完全不care了。做英语练习的时候也是类似的状态，只关注完形填空题型相关的错题。\u003c/p\u003e\n\u003cp\u003e后面的结果出乎意料。我做完形填空错误数量从13个慢慢降到7-8个，再降到3-4个，最后基本维持在0-2个。与此同时，英语总分在一个月内从平均110分涨到了140分往上，从一开始英语倒数第一，到后面考进了前10。\u003c/p\u003e\n\u003cp\u003e一开始我以为是刻意练习的功劳。但听完这个博主的解释之后，我开始重新想这件事——刻意练习可能只是形式，背后真正在起作用的，似乎是另一件事。\u003c/p\u003e\n\u003cp\u003e做题的过程中，我能感受到一个很明显的变化。刚开始刷题时，脑子里什么都在想但什么也没想清楚。到了后期，做题的感觉越来越不一样——不是说变得更轻松了，而是思考的内容明显变少了，思考的方向变得明显清晰了，开始能感知到\u0026quot;这道题在考什么\u0026quot;、\u0026ldquo;我哪里比较薄弱\u0026rdquo;。\u003c/p\u003e\n\u003cp\u003e这个变化让我觉得，可能不是我变聪明了，而是我主动降低了一些大脑负荷。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"工作记忆和认知负荷\"\u003e工作记忆和认知负荷\u003c/h2\u003e\n\u003cp\u003e带着这个疑问，我后来找到了一些相关的研究。\u003c/p\u003e\n\u003cp\u003e认知心理学里有个概念叫\u003cstrong\u003e工作记忆\u003c/strong\u003e（Working Memory），可以理解成大脑里用来\u0026quot;当下处理信息\u0026quot;的临时空间。这块空间非常有限，研究表明人类工作记忆一次大约只能同时处理4到7个独立的信息块 \u003csup id=\"fnref:1\"\u003e\u003ca href=\"#fn:1\" class=\"footnote-ref\" role=\"doc-noteref\"\u003e1\u003c/a\u003e\u003c/sup\u003e。\u003c/p\u003e\n\u003cp\u003eSweller在1988年提出的\u003cstrong\u003e认知负荷理论\u003c/strong\u003e（Cognitive Load Theory）在此基础上进一步指出 \u003csup id=\"fnref:2\"\u003e\u003ca href=\"#fn:2\" class=\"footnote-ref\" role=\"doc-noteref\"\u003e2\u003c/a\u003e\u003c/sup\u003e，认知资源是固定的总量，当低层次的操作占用了大量资源，留给真正学习的空间就少了。技能从生疏到熟练，心理学家Fitts和Posner描述了三个阶段 \u003csup id=\"fnref:3\"\u003e\u003ca href=\"#fn:3\" class=\"footnote-ref\" role=\"doc-noteref\"\u003e3\u003c/a\u003e\u003c/sup\u003e：先是每个细节都要有意识地控制，极度消耗工作记忆；然后动作开始串联，有意识监控逐渐减少；最后达到自动化，执行几乎不再消耗工作记忆，意识可以去处理其他事情。\u003c/p\u003e\n\u003cp\u003e用这个框架回头看，我高三的经历似乎说得通了。之前没有采用刻意刷题的方式前，我每切换一次题型，工作记忆被\u0026quot;这类题的做题技巧有哪些\u0026quot;、\u0026ldquo;回忆语法的考察方式\u0026quot;这些基础但是不重要的操作塞满，没有余量去感知更重要的知识。而控制刷题类型的好处在于做题技巧是固定的，同时每种语法在这些题型中的考察方式也类似，我可以真正只关注到语法使用本身，从而快速的提升自己的英语能力。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"当家教时的反面教材\"\u003e当家教时的反面教材\u003c/h2\u003e\n\u003cp\u003e这个理解让我想到自己当家教时犯的一个错误。\u003c/p\u003e\n\u003cp\u003e因为自己的英语高效提升，我后来去当了两次英语家教，想把这套方法分享给学生。我让他们也专门刷完形填空，但每次做完题，我会把所有错误的知识点全部讲一遍——一道题做下来，发现可能有10个知识点需要补充，就全讲了。另外，我还喜欢做知识拓展，因为在我自己的经历里，把知识点串联起来对巩固很有帮助。\u003c/p\u003e\n\u003cp\u003e但是效果很差。\u003c/p\u003e\n\u003cp\u003e回头看，我当时大概是把自己的认知状态投射到了学生身上。对我来说，那10个知识点在脑子里可能已经是2-3个更大的模式；对基础薄弱的学生来说，这是10个毫无关联的全新信息，早就超出他们工作记忆的上限了。知识拓展对我有用，是因为我已经有了稳定的基础，新东西可以挂上去；对他们来说，多余的信息可能只是额外的负担。\u003c/p\u003e\n\u003cp\u003eSweller的研究里有个叫\u003cstrong\u003e专家反转效应\u003c/strong\u003e（Expertise Reversal Effect）的现象 \u003csup id=\"fnref:4\"\u003e\u003ca href=\"#fn:4\" class=\"footnote-ref\" role=\"doc-noteref\"\u003e4\u003c/a\u003e\u003c/sup\u003e：对专家有效的教学方式，对新手往往是有害的。专家太清楚知识之间的关联，反而感知不到基础薄弱者面对同样信息时的认知压力。\u003c/p\u003e\n\u003cp\u003e更合适的做法，或许是只让他们关注那些最简单、最常见的错误，其余的暂时忽略，等在这一层建立了稳定的感觉，再引入下一层的复杂性。损失一些题目表面上的\u0026quot;利用率\u0026rdquo;，但认知负荷控制住了，进步可能反而会快一些。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"深度学习的联系\"\u003e深度学习的联系\u003c/h2\u003e\n\u003cp\u003e在找资料的过程中，我发现深度学习领域有一些有意思的研究，和这个思路不谋而合。\u003c/p\u003e\n\u003cp\u003eBengio等人在2009年提出的\u003cstrong\u003e课程学习\u003c/strong\u003e（Curriculum Learning）发现，按照从易到难的顺序训练神经网络，模型收敛更快，泛化能力更强 \u003csup id=\"fnref:5\"\u003e\u003ca href=\"#fn:5\" class=\"footnote-ref\" role=\"doc-noteref\"\u003e5\u003c/a\u003e\u003c/sup\u003e。另一项研究则发现，神经网络在训练过程中会优先拟合\u003cstrong\u003e低频特征\u003c/strong\u003e（全局结构、普遍规律），然后才逐渐拟合\u003cstrong\u003e高频特征\u003c/strong\u003e（局部细节、特殊情况） \u003csup id=\"fnref:6\"\u003e\u003ca href=\"#fn:6\" class=\"footnote-ref\" role=\"doc-noteref\"\u003e6\u003c/a\u003e\u003c/sup\u003e。换句话说，在训练初期，那些高频的细节信号对网络来说更像是噪声，不仅没有帮助，反而会干扰模型对基础规律的学习。\u003c/p\u003e\n\u003cp\u003e这让我觉得，或许对于学习初期的人来说，过多的细节和拓展知识，并不像直觉上感觉的那样是\u0026quot;赚到了\u0026quot;，反而可能因为超出当前处理能力而变成噪声——被大脑直接过滤掉，什么也没留下，有时还会影响到真正有价值内容的学习。\u003c/p\u003e\n\u003cp\u003e一边是人脑，一边是人工神经网络，两者在完全不同的背景下走向了类似的结论。我不确定这是否意味着什么更深层的东西，但这个巧合本身让我觉得挺有意思的。\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"想到哪写到哪\"\u003e想到哪写到哪\u003c/h2\u003e\n\u003cp\u003e我没有什么实践上的结论，只是把这些零碎的观察和思考写下来。\u003c/p\u003e\n\u003cp\u003e如果这些想法有一个共同的方向的话，大概是：学习的瓶颈，可能不在于接收了多少信息，而在于认知资源有没有被集中在当前能够消化的那一层上。信息量超出处理能力，不会带来更快的进步，只会制造更多的混乱。\u003c/p\u003e\n\u003cp\u003e就像现在信息爆炸的时代，我常会有一种信息不过脑的焦虑，现在想来可能就是因为现代社会过多的信息，对于还在和数万年前古人类用着同等型号大脑的现代人来说，实在是有一些认知负荷过载了。\u003c/p\u003e\n\u003chr\u003e\n\u003cdiv class=\"footnotes\" role=\"doc-endnotes\"\u003e\n\u003chr\u003e\n\u003col\u003e\n\u003cli id=\"fn:1\"\u003e\n\u003cp\u003eMiller, G. A. (1956). The magical number seven, plus or minus two: Some limits on our capacity for processing information. \u003cem\u003ePsychological Review\u003c/em\u003e, 63(2), 81–97. Cowan, N. (2001). The magical number 4 in short-term memory. \u003cem\u003eBehavioral and Brain Sciences\u003c/em\u003e, 24(1), 87–114.\u0026#160;\u003ca href=\"#fnref:1\" class=\"footnote-backref\" role=\"doc-backlink\"\u003e\u0026#x21a9;\u0026#xfe0e;\u003c/a\u003e\u003c/p\u003e","title":"从\"绝活哥\"到认知负荷控制"},{"content":"一.论文总览 问题背景： 现有的将多模态大模型（Multimodal Large Language Model, MLLM）引入自动驾驶领域的方法中，大多不具备3D场景理解能力，但这一能力对于自动驾驶场景而言是不可或缺的，以一个驾驶场景中常见的问题为例：\u0026ldquo;询问当前车道是否可以左转\u0026rdquo;，该问题看似在做一个简单的语义判断，本质上需要涉及到对车道与自车的几何关系判断、车道与交通灯语义、地面标识语义的匹配，要回答这些问题，需要 MLLM 模型将 2D 理解和推理能力扩展到复杂的 3D场景中。 现有 MLLM 模型按照对图像的处理方式的不同，可以分为两种流派：一种是以 Flamingo 、BLIP-2、Qwen等方法为代表，以交叉注意力机制为基础，特点是不论图像分辨率都处理成统一长度的 token 序列，而另一种是以 Vit、 BLIP、LLAVA 为代表，以自注意力机制为基础，每个 token 用于代表固定像素大小的图像局部信息，这意味着不同的分辨率图像对应变长的 token 序列。对于自动驾驶场景而言，多视角、高分辨率、连续帧（视频）是感知任务的数据特点，而变长序列代表时延、不稳定。所以作者以 BLIP-2 结构为基础，引入 Stream-PETR（作者之前的工作），构建用于自动驾驶场景的 3D MLLM。 过去的 Benchmark 大多采用简单的 QA 的形式进行评测，已有工作证明端到端自动驾驶目前 open-loop 评测方式的局限性。另外，这种评测方式驱动的方法也无法完全利用到大语言模型强大的涌现能力。对于自动驾驶场景，类似于世界模型的反事实推断能力是更符合人类思考方式和习惯的。作者希望仿照 LLAVA 的做法，提出一种高效的数据构建方式，用于构建大规模 VQA 数据集，一方面用于 MLLM 的 Instruction-tuning 训练，另一方面用于评测。 贡献： 一种具有3d 能力的vision-language model结构，将多模态大语言模型用于自动驾驶场景中的3d场景任务； 一种基于 GPT-4o 的数据构建方法，用于生成自动驾驶场景的 VQA 问答数据，包括反事实推断形式的问答。 二.方法 结构总览： 图像编码器：作者采用的是基于 Clip 结构的 Eva-02 模型作为图像编码器，通过多视角图像特征提取 3d 信息，这一部分没太多需要讲的。 Q-Former3D：参考 BLIP-2 中的 Q-Former 结构，这里作者敏锐地发现了 Q-Former 结构和 Petr 系列模型结构的相似之处，将结构引入的同时也引入了 3D 目标检测任务作为辅助监督。 LLM：之前的结构的作用在于提取 3D 信息特征，最终需要对齐到 LLM 模型能够理解的特征空间，进行 VQA 问答。 Q-Former3D: 作为最能体现作者贡献的模块，这一部分选择 Stream-PETR 出于以下考虑：PETR 所代表的稀疏 BEV 建模方式，适用于检测任务，相比于需要构建 dense bev feature 的方法来说，需要更小的计算量，同时加快模型的推理速度，毕竟OmniDrive 的核心在于多模态大模型的能力，检测任务仅作为辅助监督，所以尽量简化降低存在感。\nQ-Former3D 整体结构基于 Stream-PETR，包括 Memery Bank时序融合模块，但这不是本文介绍的重点，先挖一个坑，会尽快在另外一篇博客中更新介绍 PETR 系列。\n这里重点介绍作者额外的设计：\n根据任务设计两种 query：用于3D 目标检测任务的 query 称为 Perception query，用于 LLM 生成文本任务的称为 Carrier query。\n$$ \\begin{array}{r} (Q, K, V)=\\left(\\left[Q_c, Q_d\\right],\\left[Q_c, Q_d\\right],\\left[Q_c, Q_d\\right]\\right), \\\\ \\tilde{Q}=\\operatorname{Multi-head} \\operatorname{Attention}(Q, K, V) \\end{array} $$$$ \\begin{array}{r} (Q, K, V)=\\left(\\left[Q_c, Q_d\\right], P_m+F_m, F_m\\right), \\\\ \\tilde{Q}=\\operatorname{Multi-head\\operatorname {Attention}(Q,K,V)} \\end{array} $$ 上述过程经过多层后得到输出，按照输入的划分，Perception query 对应的输出会进入到 PETR head 进行前景目标检测任务；Carrier query 对应的输出会经过 MLP 映射成 LLM 的输入维度，然后送入 LLM 进行文本生成，这一做法和 LLAVA 相同。从结果来看，Carrier query 的主要作用就是用于视觉语言特征的对齐，同时利用到 3d 几何先验信息和 Perception query 中的感知检测结果信息。\n训练策略 训练整体分为两个阶段：\n2d 预训练阶段：\n这一部分实际上是一个完整的 LLM 训练过程，也需要分为两个部分：\n预训练：这个阶段不涉及到perception query的训练，所以剩余的部分和一个普通的MLLM的训练方式一样，这里由于模型结构是Q-Former形式的，所以采用BLIP-V2的训练方式，但是由于没有BLIP中的多个decoder结构，所以只能使用文本建模loss进行监督，没有使用BLIP中的其他的对比损失和匹配损失。这里主要就是完成了Q-Former的训练，实现了从图像特征到文本特征的对齐。\nInstruction-tuning阶段：使用的也是LLAVA-V1.5 生成的数据集。\n3d fine-tune 阶段：\n这一阶段的目标是增强模型的3D场景理解能力，同时尽可能多地保留其原有的2D语义理解能力。\n总体使用Lora 微调，以较小的学习率对视觉编码器和大型语言模型进行微调，并以相对较大的学习率训练Q-Former3D。\n以上就是对整体方法框架的解读，可以看出的是：模型整体的3d 能力核心实际是在于 Q-Former3D 模块，另外复现发现方法整体对于学习率的设置比较敏感，并且在检测任务明显未达到收敛时，LLM 的planning任务的损失已有明显的收敛倾向，作者在 issue 中有提到，检测任务的收敛需要 24 个 epoch 左右，而最终作者提供的训练配置只有 6 个 epoch，猜测这一现象是与 Q-Former3D 的微调阶段的大学习率设置有关。\n三. 结果 由于作者主要的卖点在于端到端自动驾驶在3d 场景的效果和反事实推断能力，所以作者并没有列出目标检测的指标，主要的卖点还是以 planning 为导向的端到端场景，所以对比的主要指标是 open-loop planning 轨迹的 l2 误差、object碰撞率、以及boundary 碰撞率 另外，关于作者提出的 benchmark，作者也进行了详细的消融实验来证明各类型数据的作用：\n四.数据生成逻辑解读 该工作的另一大贡献是基于 GPT-4o 的数据生成方法，虽然总体上与 LLAVA 的做法相似，但作者对于自动驾驶场景中的任务做了一些别出心裁的设计，这一部分会详细解读作者是如何处理 Nuscenes 数据集信息，提供给 GPT 作为 prompt，用于生成包含反事实推断的对话。\n首先，作者提供的示例图中，将提供的 prompt 分为以下几种信息:\nImage：来自 nuscenes 数据集中的 6 个摄像头画面，按照前视和后视划分连接。需要注意的是，image prompt 在 VQA 生成时是没有使用的，这一做法猜测可能是防止 GPT-4o 的幻觉，也可能仅仅是延续 LLAVA 中的做法。\nCaption：对驾驶场景的文本描述，实际上这一部分也是先由 GPT生成的，后续作为生成其他内容的 prompt。 Lane-object association：利用 nuscenes 数据集中的检测信息和 lane 几何信息做匹配，将匹配的结果以目录的形式提供给 GPT，便于 GPT 对整个驾驶场景中各 lane 构建整体感知。 Simulated decision and trajectory：作者基于深度优先搜索算法，将 nuscenes 数据集中提供的 lane 中心线进行连接，形成轨迹，经过逻辑过滤后，用于让 GPT 生成反事实推断相关的问答。 Expert decision and trajectory：相对于上一个，这里的\u0026quot;Expert\u0026quot;意义为 nuscenes 中的真值轨迹信息，代表安全的驾驶轨迹，用于让 GPT 理解驾驶意图和几何坐标信息（作者这里的坐标是 2d ego坐标系，第一维正值代表前向，第二维正值代表左向）。 代码解读 总览：\n用到的数据生成脚本分为三个： desc.py：对应上图中 caption 的生成 conversation.py planning_vision.py 问题答案对生成逻辑，对应transform_3d.py文件中两个函数： preprocess_vqa：对应上图中 Conversation 的内容 online_vqa 1. desc.py: 任务描述： prompt中提供环视图像和当前车辆的行车状态、驾驶行为描述（专家轨迹），需要gpt完成两个任务：\n在一段话中总结驾驶场景。 -在此任务中，应该提供驾驶场景的详细描述，例如指定道路状况。 -注意任何特定设置（停车场、十字路口、环形交叉口）、交通要素（行人、车辆、交通标志/灯）、一天中的时间和天气。 分析当前驾驶行为。 -任务是使用给定的图像简要解释驾驶意图，假设你在真实场景中驾驶。 -您应该了解提供的图像，首先确定正确的驾驶决策/意图，推理出驾驶员在这种情况下应该特别注意的事项，并以要点形式列出。 代码解读：\nparser = argparse.ArgumentParser(description=\u0026#34;Process NuScenes data.\u0026#34;) parser.add_argument(\u0026#39;--base_path\u0026#39;, type=str, default=\u0026#39;data/nuscenes/\u0026#39;, help=\u0026#39;Base path to the NuScenes data.\u0026#39;) parser.add_argument(\u0026#39;--lane_info_path\u0026#39;, type=str, default=\u0026#39;data/nuscenes/data_dict_sample.pkl\u0026#39;, help=\u0026#39;Path to the lane info pickle file.\u0026#39;) parser.add_argument(\u0026#39;--info_file\u0026#39;, type=str, default=\u0026#39;data/nuscenes/nuscenes2d_ego_temporal_infos_train.pkl\u0026#39;, help=\u0026#39;Path to the info file (e.g., nuscenes2d_ego_temporal_infos_train.pkl).\u0026#39;) parser.add_argument(\u0026#39;--output_dir\u0026#39;, type=str, default=\u0026#39;./desc/train/\u0026#39;, help=\u0026#39;Directory to save the output JSON files.\u0026#39;) parser.add_argument(\u0026#39;--n_process\u0026#39;, type=int, default=8, help=\u0026#39;Number of processes to use.\u0026#39;) parser.add_argument(\u0026#39;--api_key\u0026#39;, type=str, required=True, help=\u0026#39;API key for OpenAI.\u0026#39;) 这里的base_path是nuscenes数据集的存放目录\nlane_info_path、info_file都是作者提供的文件，其中：\nlane_info_path存放的是openlane_v2数据集中，各个传感器的路径、内外参、gt中各个lane、traffic_element以及它们之间的拓扑结构信息。 info_file存放的是nuscense数据中各个scene中有效目标的相关信息， 包括lidar数据和cam数据的存放路径、gt的bbox以及类别信息等等 接下来按info_file中的数据，分成各个task：\ntasks = [(d, lane_infos, traj_gen, output_dir, api_key, sys_prompt) for d in data] # Call track_parallel_progress mmengine.track_parallel_progress( func=preprocess_single, tasks=tasks, nproc=n_process, keep_order=True, # Results will be in the order tasks were given ) 处理逻辑:\n首先从info_file找到对应的lane_info索引信息，然后从lane_info中拿到 中心线的points位置信息，数据格式[n_lane, 11, 3]，猜测是每条lane都由11个关键点坐标来表示 gt_planning\\gt_planning_mask（这里的维度是[n, 3], 前两维是坐标，后一维是yaw角） gt_fut_traj\\gt_fut_traj_mask（是每一个周围其他目标的预测轨迹，可以和gt对应上） if \u0026#39;lane_info\u0026#39; in data.keys(): lane_info = lane_infos[data[\u0026#39;lane_info\u0026#39;]] lane_pts = [lane[\u0026#39;points\u0026#39;] for lane in lane_info[\u0026#39;annotation\u0026#39;][\u0026#39;lane_centerline\u0026#39;]] traj, mask = data[\u0026#39;gt_planning\u0026#39;][0], data[\u0026#39;gt_planning_mask\u0026#39;][0] gt_fut_traj, gt_fut_traj_mask = data[\u0026#39;gt_fut_traj\u0026#39;], data[\u0026#39;gt_fut_traj_mask\u0026#39;] planning_trajs, full_paths = traj_gen.generate_traj(lane_pts) expert_info = describe_expertv2(traj, mask, lane_pts, full_paths, gt_fut_traj, gt_fut_traj_mask, data[\u0026#39;gt_fullnames\u0026#39;], data[\u0026#39;gt_boxes\u0026#39;], data[\u0026#39;gt_attrs\u0026#39;]) 接下来调用generate_traj函数生成轨迹：\n利用已有的中心线point坐标，使用dfs算法和一些过滤逻辑生成所有可行的path以及path上具体的关键点2维坐标信息\n在self.planning_anchor中预定义了300个轨迹，每次生成轨迹时会从这里面随机挑选三个出来加入到备选轨迹中\n备选轨迹的生成是先对传入的all_path_pts拟合一个曲线，这里的设置是用10个点来拟合，所以得到的controj_points是[10, 2]，时间t设置的是[6, 1]、然后计算yaw角[6, 1]、轨迹坐标plan_traj[6, 2]，reshape为[1, 12]，最终的轨迹表示 格式为[1, 12 + 6 + 6]=[1, 24]\n和之前随机加入的轨迹一起，随机打乱后挑选出前5个作为结果返回\ndef generate_traj(self, lane_pts, max_traj=5): num_anchors = self.planning_anchor.shape[0] random_list = [random.randint(0, num_anchors-1) for _ in range(3)] all_paths_pts, full_paths = self.search_path(lane_pts) plan_trajs = [] for i in random_list: plan_trajs.append(self.planning_anchor[i].reshape(1, -1)) for path in all_paths_pts: t = self.generate_t(self.step) t = np.cumsum(t) controj_points = fit_bezier_Endpointfixed(path, 10) plan_yaw = bezier_tangent_angles(controj_points, t).reshape(-1, len(t)) plan_traj = control_points_to_lane_points(controj_points, t).numpy().reshape(-1, 2*len(t)) plan_trajs.append(np.concatenate([plan_traj, np.ones_like(plan_yaw), plan_yaw], -1)) random.shuffle(plan_trajs) plan_trajs = plan_trajs[:max_traj] return plan_trajs, full_paths 调用describe_expertv2函数，为了得到当前状态的描述信息： def describe_expertv2(gt_planning, planning_mask, lane_pts, full_paths, pred_traj, pred_traj_mask, names, bboxes, attrs): #nuscenes数据集中的gt轨迹，前两维为坐标，最后一维是yaw角 planning_traj = gt_planning[..., :2] planning_yaw = gt_planning[..., 2] mask = planning_mask.any(axis=1) combined_data = list(zip(names, bboxes, attrs, pred_traj, pred_traj_mask)) #初级过滤，根据bbox的坐标，横坐标或者纵坐标大于50（距离太远）的目标直接去掉 filtered_data = [(name, bbox, attr, traj, traj_mask) for name, bbox, attr, traj, traj_mask in combined_data if abs(bbox[0]) \u0026lt;= 50 and abs(bbox[1]) \u0026lt;= 50] all_names = [] all_dists = [] all_xy = [] for name, bbox, attr, traj, traj_mask in filtered_data: if attr == \u0026#39;\u0026#39;: full_name = name else: attr = attr.split(\u0026#39;.\u0026#39;)[1] full_name = name + f\u0026#39;.{attr}\u0026#39; #累加轨迹值，表示该物体的相对坐标 traj = np.cumsum(traj, axis=1) #和该bbox位置相加后可以得到绝对坐标值 traj += bbox[:2] masked_planning = gt_planning[mask] masked_traj = traj[traj_mask.astype(bool)][:6] #计算该目标离自车的距离 dist_rec = np.linalg.norm(bbox[:2]) if masked_planning.size == 0 or masked_traj.size == 0: l2_norm = dist_rec else: min_len = min(len(masked_planning), len(masked_traj)) l2_norm = np.linalg.norm(masked_planning[:min_len][..., :2] - masked_traj[:min_len], axis=1).min() #计算该目标的轨迹与自车轨迹的最小距离，用于判断两个轨迹是否相关 dist = min(dist_rec, l2_norm) #如果小于10，就加入到备选项中 if dist \u0026lt;= 10.0: all_names.append(full_name) all_dists.append(dist) all_xy.append(bbox[:2]) #得到速度，用于判断速度状态： Stopped/Crawling/Moving slowly/Moderate speed/Moving fastly #利用真值轨迹，判断车道变换行为： Left Lane Changing/Right Lane Changing/Lane Keeping #根据yaw角，判断行车行为： Go Straight/Left U-turn/Right U-turn/Left Turn/Right Turn ego_vel = calculate_speed(planning_traj, mask) speed_state = judge_speed_changes(ego_vel[..., 0]) self_action = f\u0026#34;Expert decision: {speed_state}\u0026#34; lane_change = detect_lane_change(gt_planning[mask], lane_pts, full_paths) turning_behavior = determine_turning_behavior(planning_yaw) if speed_state not in [\u0026#34;Stopped\u0026#34;, \u0026#34;Unknown\u0026#34;]: if turning_behavior == \u0026#34;Go Straight\u0026#34;: self_action = self_action + \u0026#34;, \u0026#34; + lane_change if not (lane_change != \u0026#34;Lane Keeping\u0026#34; and turning_behavior == \u0026#34;Go Straight\u0026#34;): self_action = self_action + \u0026#34;, \u0026#34; + turning_behavior formatted_points = \u0026#39;, \u0026#39;.join(f\u0026#34;({format_number(point[0], 2)}, {format_number(point[1], 2)})\u0026#34; for point in planning_traj[mask]) self_traj = f\u0026#34;Expert trajectory: [PT, {formatted_points}].\u0026#34; ego_state = [self_action] #description大概率会由当前速度状态 + 变道倾向/转弯倾向 构成 description = \u0026#39;\\n\u0026#39;.join(ego_state) return description 接下来处理prompt：\n将前后各三张图片拿到后进行concat + resize操作，从[4800, 900] 到 [1536, 512]\n然后将数据格式处理成Base64，用于HTTP传输\nfront_image_paths = [data[\u0026#39;cams\u0026#39;][\u0026#39;CAM_FRONT_LEFT\u0026#39;][\u0026#39;data_path\u0026#39;], data[\u0026#39;cams\u0026#39;][\u0026#39;CAM_FRONT\u0026#39;][\u0026#39;data_path\u0026#39;], data[\u0026#39;cams\u0026#39;][\u0026#39;CAM_FRONT_RIGHT\u0026#39;][\u0026#39;data_path\u0026#39;]] back_image_paths = [data[\u0026#39;cams\u0026#39;][\u0026#39;CAM_BACK_LEFT\u0026#39;][\u0026#39;data_path\u0026#39;], data[\u0026#39;cams\u0026#39;][\u0026#39;CAM_BACK\u0026#39;][\u0026#39;data_path\u0026#39;], data[\u0026#39;cams\u0026#39;][\u0026#39;CAM_BACK_RIGHT\u0026#39;][\u0026#39;data_path\u0026#39;]] front_image, back_image = create_combined_image(front_image_paths, back_image_paths) front_image = front_image.resize((1536, 512)) back_image = back_image.resize((1536, 512)) encoded_front_image = encode_image(front_image) encoded_back_image = encode_image(back_image) while True: try: hat_completion = client.chat.completions.create( model=\u0026#34;gpt-4o\u0026#34;, messages=[ **** ], temperature=0.7, top_p=0.7, max_tokens=2000, ) result = json.loads(replace_newlines_in_json_string(hat_completion.choices[0].message.content)) print(result) with open(output_file_path, \u0026#39;w\u0026#39;) as f: json.dump(result, f, indent=4) except Exception as e: print(e) else: break 2. conversation.py: 任务描述：利用上一步中生成的描述信息，生成对应的对话问答内容，但这里还没有涉及反事实推断的生成，只是一些比较简单宽泛的问答。\n代码解读：\n这里不再需要传入lane有关的信息，而是需要拿到上一步脚本中生成的desc文本。\nparser = argparse.ArgumentParser(description=\u0026#34;Process NuScenes data.\u0026#34;) parser.add_argument(\u0026#39;--info_file\u0026#39;, type=str, default=\u0026#39;data/nuscenes/nuscenes2d_ego_temporal_infos_train.pkl\u0026#39;, help=\u0026#39;Path to the info file (e.g., nuscenes2d_ego_temporal_infos_train.pkl).\u0026#39;) parser.add_argument(\u0026#39;--desc_path\u0026#39;, type=str, default=\u0026#39;./desc/train/\u0026#39;, help=\u0026#39;Path to the description files directory.\u0026#39;) parser.add_argument(\u0026#39;--output_dir\u0026#39;, type=str, default=\u0026#39;./conv/train/\u0026#39;, help=\u0026#39;Directory to save the output JSON files.\u0026#39;) parser.add_argument(\u0026#39;--n_process\u0026#39;, type=int, default=8, help=\u0026#39;Number of processes to use.\u0026#39;) parser.add_argument(\u0026#39;--api_key\u0026#39;, type=str, required=True, help=\u0026#39;API key for OpenAI.\u0026#39;) args = parser.parse_args() main(args.info_file, args.desc_path, args.output_dir, args.n_process, args.api_key) 接下来就直接利用已有信息来生成prompt：\n首先传入的是desc文本中的description和action两个text； 然后是两个拼接后的前后图像； 任务： 分析和解释当前的驾驶行为和相关的驾驶场景，设计一个你和一个人之间的对话，询问这个驾驶场景。提出不同的问题并给出相应的答案。不要问任何不能确定回答的问题。 还包括与图像中的内容相关的复杂问题，例如，询问场景中对象的背景知识，要求讨论场景中发生的事件。在回答复杂问题时提供详细的答案。例如，给出详细的例子或推理步骤，使内容更具说服力和组织性。 output_file_path = osp.join(output_dir, data[\u0026#39;token\u0026#39;] + \u0026#34;.json\u0026#34;) os.makedirs(osp.dirname(output_file_path), exist_ok=True) if not osp.isfile(osp.join(output_dir, data[\u0026#39;token\u0026#39;]+\u0026#39;.json\u0026#39;)): with open(osp.join(desc_path, data[\u0026#39;token\u0026#39;] + \u0026#34;.json\u0026#34;), \u0026#39;r\u0026#39;) as f: scene_keywords = json.load(f) user_prompt = f\u0026#34;\u0026#34;\u0026#34; Description: {scene_keywords[\u0026#34;description\u0026#34;]} Action: {scene_keywords[\u0026#34;action\u0026#34;]} \u0026#34;\u0026#34;\u0026#34; front_image_paths = [data[\u0026#39;cams\u0026#39;][\u0026#39;CAM_FRONT_LEFT\u0026#39;][\u0026#39;data_path\u0026#39;], data[\u0026#39;cams\u0026#39;][\u0026#39;CAM_FRONT\u0026#39;][\u0026#39;data_path\u0026#39;], data[\u0026#39;cams\u0026#39;][\u0026#39;CAM_FRONT_RIGHT\u0026#39;][\u0026#39;data_path\u0026#39;]] back_image_paths = [data[\u0026#39;cams\u0026#39;][\u0026#39;CAM_BACK_LEFT\u0026#39;][\u0026#39;data_path\u0026#39;], data[\u0026#39;cams\u0026#39;][\u0026#39;CAM_BACK\u0026#39;][\u0026#39;data_path\u0026#39;], data[\u0026#39;cams\u0026#39;][\u0026#39;CAM_BACK_RIGHT\u0026#39;][\u0026#39;data_path\u0026#39;]] front_image, back_image = create_combined_image(front_image_paths, back_image_paths) front_image = front_image.resize((1536, 512)) back_image = back_image.resize((1536, 512)) encoded_front_image = encode_image(front_image) encoded_back_image = encode_image(back_image) while True: try: hat_completion = client.chat.completions.create( model=\u0026#34;gpt-4o\u0026#34;, messages=[ ****** ], temperature=0.9, top_p=0.7, max_tokens=2000, ) result = json.loads(replace_newlines_in_json_string(hat_completion.choices[0].message.content)) with open(osp.join(output_dir, data[\u0026#39;token\u0026#39;]+\u0026#39;.json\u0026#39;), \u0026#39;w\u0026#39;) as f: json.dump(result, f, indent=4) except Exception as e: print(e) continue else: break 3. prompt_vision.py: 任务描述：这次和前两个脚本有一个明显的不同在于：没有提供给模型image作为信息，只提供了各种文本形式的描述信息，包括模拟轨迹和专家轨迹，以及车道和目标的关联信息，用于生成包括反事实推断的复杂问答。\n准备的prompt内容包括：\nScene Info：目录结构的车道线，以及车道线上需要注意的物体类别、坐标信息\nScene keywords：desc.py生成的description 和 action\nPlanning info：当前真值轨迹对应的状态、驾驶指令信息，还包括轨迹上其他需要注意的目标的坐标信息\nSimulated info：生成的多个模拟轨迹对应的状态、指令，以及具体的轨迹坐标，和该轨迹对应的语义真值信息：是否安全，\nsys_prompt: 告诉gpt以上信息的对应意义，以及需要gpt完成的任务：\n​ 设计4个关于当前驾驶场景的问答对。\n提问多样化，改写以下问题为更自然的表述，并给出详细答案，包括基于当前输入观察的数值信息。\n​ 问题示例：\nQ1: 是否存在可能影响您驾驶行为的交通元素？如果有，它们是什么？\nQ2: 您的下一步行动是什么？为什么？\n参考专家/示例轨迹设计两个类似的问题：\nQ3: 如果您遵循轨迹 [PT, (x1, y1), (x2, y2), (x3, y3)] [在此处替换为示例轨迹]，会发生什么？ Q4：…… 代码逻辑：\n传入参数：\n数据集有关信息nuscenes_info_file data_dict_file 树形结构的道路信息 desc_path desc文件存放目录 parser = argparse.ArgumentParser(description=\u0026#34;Process driving scenarios and generate QA pairs.\u0026#34;) parser.add_argument(\u0026#39;--base_path\u0026#39;, type=str, default=\u0026#39;./data/nuscenes/\u0026#39;, help=\u0026#39;Base path to the data directory.\u0026#39;) parser.add_argument(\u0026#39;--nuscenes_info_file\u0026#39;, type=str, default=\u0026#39;nuscenes2d_ego_temporal_infos_train.pkl\u0026#39;, help=\u0026#39;Nuscenes info file.\u0026#39;) parser.add_argument(\u0026#39;--data_dict_file\u0026#39;, type=str, default=\u0026#39;data_dict_sample.pkl\u0026#39;, help=\u0026#39;Data dictionary file.\u0026#39;) parser.add_argument(\u0026#39;--output_dir\u0026#39;, type=str, default=\u0026#39;./vqa/train\u0026#39;, help=\u0026#39;Output directory for results.\u0026#39;) parser.add_argument(\u0026#39;--desc_path\u0026#39;, type=str, default=\u0026#39;./desc/train/\u0026#39;, help=\u0026#39;Path to the description files directory.\u0026#39;) parser.add_argument(\u0026#39;--api_key\u0026#39;, type=str, required=True, help=\u0026#39;API key for OpenAI.\u0026#39;) parser.add_argument(\u0026#39;--n_process\u0026#39;, type=int, default=8, help=\u0026#39;Number of parallel processes to use.\u0026#39;) args = parser.parse_args() 主要调用函数包括：\n处理人行斑马线 get_crosswalks函数，主要逻辑是将斑马线区域转换为几何矩形表示； generate_traj函数：和desc文件中一样通过dfs得到不同的可行轨迹 scene_description函数：根据已有的各种道路目标生成一个目录式的结构，包含crosswalk、lane和对应obj describe_expert函数：对真值轨迹生成一个描述，以及其需要注意的obj类别、坐标等信息 gt_fut_traj, gt_fut_traj_mask = data[\u0026#39;gt_fut_traj\u0026#39;], data[\u0026#39;gt_fut_traj_mask\u0026#39;] crosswalks = get_crosswalks(data[\u0026#39;map_geoms\u0026#39;]) planning_trajs, full_paths = traj_gen.generate_traj(lane_pts) scene_info, lanes_red = scene_description(traj, mask, lane_info, data[\u0026#39;gt_fullnames\u0026#39;], data[\u0026#39;gt_boxes\u0026#39;], data[\u0026#39;gt_velocity\u0026#39;], data[\u0026#39;gt_attrs\u0026#39;], lane_pts, crosswalks) expert_info = describe_expert(traj, mask, lane_pts, full_paths, gt_fut_traj, gt_fut_traj_mask, data[\u0026#39;gt_fullnames\u0026#39;], data[\u0026#39;gt_boxes\u0026#39;], data[\u0026#39;gt_attrs\u0026#39;]) scene_description函数：\ndef scene_description(gt_planning, planning_mask, lane_info, objects_list, bboxes, velocity, attrs, lane_pts, crosswalks): output_lines = [] #根据lane_info中的traffic_element信息，来判断是否有交通灯存在，生成对应的文本：Traffic Light Existing: False|True tl_description = describe_tl(lane_info) output_lines.append(tl_description) #得到所有车道的description包含方向、具体的point坐标和相关交通元素的分类，和红灯对应车道的信息 lane_description, lanes_red = describe_lanes(lane_info) #得到crosswalk的坐标描述 crosswalk_description = describe_crosswalks(crosswalks) #将交通参与者与车道和斑马线关联 lane_objects, crosswalk_objects = describe_objects2lane(gt_planning, planning_mask, objects_list, bboxes, velocity, attrs, lane_pts, crosswalks) if len(objects_list) == 0: output_lines.append(f\u0026#34;No traffic participants observed in the scene.\u0026#34;) #在lane_objects中加入自车的信息，ego_index表示自车所在的车道index lane_objects, ego_index = add_ego2lane(gt_planning, planning_mask, lane_pts, lane_objects) #利用以上的信息来构成最终输出的description_scene,返回的是论文中所说的树形结构的数据 for i, crosswalk_desc in enumerate(crosswalk_description): output_lines.append(f\u0026#34;├── {crosswalk_desc}\u0026#34;) if i in crosswalk_objects.keys(): for obj_desc in crosswalk_objects[i]: output_lines.append(f\u0026#34;│ ├── {obj_desc}\u0026#34;) for i, lane_desc in enumerate (lane_description): if i == ego_index: lane_desc = lane_desc.replace(\u0026#34;with-flow, \u0026#34;, \u0026#34;your current \u0026#34;) output_lines.append(f\u0026#34;├── {lane_desc}\u0026#34;) if i in lane_objects.keys(): for obj_desc in lane_objects[i]: output_lines.append(f\u0026#34;│ ├── {obj_desc}\u0026#34;) if \u0026#39;others\u0026#39; in lane_objects: output_lines.append(\u0026#34;├── Other Lanes/Roadside\u0026#34;) for obj_desc in lane_objects[\u0026#39;others\u0026#39;]: output_lines.append(f\u0026#34;│ ├── {obj_desc}\u0026#34;) return \u0026#39;\\n\u0026#39;.join(output_lines), lanes_red describe_expert函数（大部分逻辑都和v2相同，不同之处在于：最后加入了与当前轨迹可能有相交的目标的类别和坐标信息）：\ndef describe_expert(gt_planning, planning_mask, lane_pts, full_paths, pred_traj, pred_traj_mask, names, bboxes, attrs): planning_traj = gt_planning[..., :2] planning_yaw = gt_planning[..., 2] mask = planning_mask.any(axis=1) combined_data = list(zip(names, bboxes, attrs, pred_traj, pred_traj_mask)) filtered_data = [(name, bbox, attr, traj, traj_mask) for name, bbox, attr, traj, traj_mask in combined_data if abs(bbox[0]) \u0026lt;= 50 and abs(bbox[1]) \u0026lt;= 50] all_names = [] all_dists = [] all_xy = [] for name, bbox, attr, traj, traj_mask in filtered_data: if attr == \u0026#39;\u0026#39;: full_name = name else: attr = attr.split(\u0026#39;.\u0026#39;)[1] full_name = name + f\u0026#39;.{attr}\u0026#39; traj = np.cumsum(traj, axis=1) traj += bbox[:2] masked_planning = gt_planning[mask] masked_traj = traj[traj_mask.astype(bool)][:6] dist_rec = np.linalg.norm(bbox[:2]) # 检查是否有空数组，如果有，则不能计算距离 if masked_planning.size == 0 or masked_traj.size == 0: l2_norm = dist_rec else: # 若两数组长度不同，取较小的长度来计算L2 Norm min_len = min(len(masked_planning), len(masked_traj)) # 计算L2 Norm l2_norm = np.linalg.norm(masked_planning[:min_len][..., :2] - masked_traj[:min_len], axis=1).min() dist = min(dist_rec, l2_norm) if dist \u0026lt;= 10.0: all_names.append(full_name) all_dists.append(dist) all_xy.append(bbox[:2]) ego_vel = calculate_speed(planning_traj, mask) speed_state = judge_speed_changes(ego_vel[..., 0]) self_action = f\u0026#34;Expert decision: {speed_state}\u0026#34; lane_change = detect_lane_change(gt_planning[mask], lane_pts, full_paths) turning_behavior = determine_turning_behavior(planning_yaw) if speed_state not in [\u0026#34;Stopped\u0026#34;, \u0026#34;Unknown\u0026#34;]: if turning_behavior == \u0026#34;Go Straight\u0026#34;: self_action = self_action + \u0026#34;, \u0026#34; + lane_change if not (lane_change != \u0026#34;Lane Keeping\u0026#34; and turning_behavior == \u0026#34;Go Straight\u0026#34;): self_action = self_action + \u0026#34;, \u0026#34; + turning_behavior formatted_points = \u0026#39;, \u0026#39;.join(f\u0026#34;({format_number(point[0], 2)}, {format_number(point[1], 2)})\u0026#34; for point in planning_traj[mask]) self_traj = f\u0026#34;Expert trajectory: [PT, {formatted_points}].\u0026#34; ego_state = [self_action, self_traj] description = \u0026#39;\\n\u0026#39;.join(ego_state) if len(all_dists): desc_near = f\u0026#34;Objects near your path: \u0026#34; for i, obj in enumerate(all_names): desc_near += f\u0026#34;{all_names[i]} at ({format_number(all_xy[i][0])}, {format_number(all_xy[i][1])})\u0026#34; if i != len(all_dists) -1: desc_near += \u0026#34;, \u0026#34; else: desc_near += \u0026#34;.\u0026#34; description = description + \u0026#34;\\n\u0026#34; + desc_near return description 运行完以上两个函数以后，接下来继续处理：\ngt_fut_traj, gt_fut_traj_mask = data[\u0026#39;gt_fut_traj\u0026#39;], data[\u0026#39;gt_fut_traj_mask\u0026#39;] crosswalks = get_crosswalks(data[\u0026#39;map_geoms\u0026#39;]) planning_trajs, full_paths = traj_gen.generate_traj(lane_pts) scene_info, lanes_red = scene_description(traj, mask, lane_info, data[\u0026#39;gt_fullnames\u0026#39;], data[\u0026#39;gt_boxes\u0026#39;], data[\u0026#39;gt_velocity\u0026#39;], data[\u0026#39;gt_attrs\u0026#39;], lane_pts, crosswalks) expert_info = describe_expert(traj, mask, lane_pts, full_paths, gt_fut_traj, gt_fut_traj_mask, data[\u0026#39;gt_fullnames\u0026#39;], data[\u0026#39;gt_boxes\u0026#39;], data[\u0026#39;gt_attrs\u0026#39;]) ego_boxes = np.array([[1.5, 0.0, 0.0, 4.08, 1.73, 0.0, 0.0, 0.0, 0.0]]) step = 6 light_seg = planning_metric.red_light_area(lanes_red) gt_agent_boxes = np.concatenate([data[\u0026#39;gt_boxes\u0026#39;], data[\u0026#39;gt_velocity\u0026#39;]], -1) gt_agent_feats = np.concatenate([data[\u0026#39;gt_fut_traj\u0026#39;][:, :6].reshape(-1, 12), data[\u0026#39;gt_fut_traj_mask\u0026#39;][:, :6], data[\u0026#39;gt_fut_yaw\u0026#39;][:, :6], data[\u0026#39;gt_fut_idx\u0026#39;]], -1) bev_seg = planning_metric.get_birds_eye_view_label(gt_agent_boxes, gt_agent_feats) e2g_r_mat = Quaternion(data[\u0026#39;ego2global_rotation\u0026#39;]).rotation_matrix e2g_t = data[\u0026#39;ego2global_translation\u0026#39;] drivable_seg = planning_metric.get_drivable_area(e2g_t, e2g_r_mat, data) all_coll_objs = [] all_red_lights = [] all_drivable = [] for traj in planning_trajs: ego_seg = planning_metric.get_ego_seg(ego_boxes, traj, add_rec=True) coll_index, red_light, out_of_drivable = planning_metric.traj_check(ego_seg, bev_seg, light_seg, drivable_seg) all_red_lights.append(red_light) all_drivable.append(out_of_drivable) coll_obj = [(data[\u0026#39;gt_fullnames\u0026#39;][idx], data[\u0026#39;gt_attrs\u0026#39;][idx], data[\u0026#39;gt_boxes\u0026#39;][idx]) for idx in coll_index] all_coll_objs.append(coll_obj) #describe_simulated函数主要是根据上述的信息，对每条模拟轨迹做判断，得到以下信息： #自车的轨迹和行为分析。 #是否闯红灯。 #是否驶出可行驶区域。 #是否与其他对象发生碰撞。 #综合生成每条轨迹的决策和安全性评价。 simulated_info = describe_simulated(step, planning_trajs, lane_pts, all_coll_objs, all_red_lights, all_drivable, full_paths) area = data[\u0026#39;location\u0026#39;].split(\u0026#34;-\u0026#34;)[0] sys_prompt = make_context(area, side) 4. preprocess_vqa函数： 任务描述：读取前面生成的desc、conv、vqa文件，将其中的内容处理成能输入给大语言模型的格式。\nkeyword：从逻辑大概看得出来，keyword是gpt对当前驾驶行为的简单描述，但文本是作者直接提供的，并没有相应的生成逻辑脚本。\nif os.path.exists(self.base_key_path+results[\u0026#39;sample_idx\u0026#39;]+\u0026#34;.json\u0026#34;): with open(self.base_key_path+results[\u0026#39;sample_idx\u0026#39;]+\u0026#34;.json\u0026#34;, \u0026#39;r\u0026#39;) as f: action = json.load(f) sources.append( [ {\u0026#34;from\u0026#34;: \u0026#39;human\u0026#39;, \u0026#34;value\u0026#34;: \u0026#34;Please shortly describe your driving action.\u0026#34;}, {\u0026#34;from\u0026#34;: \u0026#39;gpt\u0026#39;, \u0026#34;value\u0026#34;: action} ] ) desc：这里是从已有的10个问题模板中随机选一个作为问题，然后将desc中的描述部分作为这个问题的答案用于监督模型：\nself.template = [ \u0026#34;What can you tell about the current driving conditions from the images?\u0026#34;, \u0026#34;What can be observed in the panoramic images provided?\u0026#34;, \u0026#34;Can you provide a summary of the current driving scenario based on the input images?\u0026#34;, \u0026#34;What can you observe from the provided images regarding the driving conditions?\u0026#34;, \u0026#34;Please describe the current driving conditions based on the images provided.\u0026#34;, \u0026#34;Can you describe the current weather conditions and the general environment depicted in the images?\u0026#34;, \u0026#34;Please describe the current driving conditions based on the input images.\u0026#34;, \u0026#34;Could you summarize the current driving conditions based on the input images?\u0026#34;, \u0026#34;Please provide an overview of the current driving conditions based on the images.\u0026#34;, \u0026#34;Can you summarize what the panoramic images show?\u0026#34;, \u0026#34;Can you describe the overall conditions and environment based on the images?\u0026#34;, \u0026#34;Could you describe the overall environment and objects captured in the images provided?\u0026#34; ] if os.path.exists(self.base_desc_path+results[\u0026#39;sample_idx\u0026#39;]+\u0026#34;.json\u0026#34;): with open(self.base_desc_path+results[\u0026#39;sample_idx\u0026#39;]+\u0026#34;.json\u0026#34;, \u0026#39;r\u0026#39;) as f: desc = json.load(f) question = random.sample(self.template, 1)[0] sources.append( [ {\u0026#34;from\u0026#34;: \u0026#39;human\u0026#39;, \u0026#34;value\u0026#34;: question}, {\u0026#34;from\u0026#34;: \u0026#39;gpt\u0026#39;, \u0026#34;value\u0026#34;: desc[\u0026#34;description\u0026#34;]} ] ) conv和vqa都是一样的处理方式，读取问题和答案：\nif os.path.exists(self.base_vqa_path+results[\u0026#39;sample_idx\u0026#39;]+\u0026#34;.json\u0026#34;): with open(self.base_vqa_path+results[\u0026#39;sample_idx\u0026#39;]+\u0026#34;.json\u0026#34;, \u0026#39;r\u0026#39;) as f: data_qa = json.load(f) for i, pair in enumerate(data_qa): sources.append( [ {\u0026#34;from\u0026#34;: \u0026#39;human\u0026#39;, \u0026#34;value\u0026#34;: pair[\u0026#34;question\u0026#34;]}, {\u0026#34;from\u0026#34;: \u0026#39;gpt\u0026#39;, \u0026#34;value\u0026#34;: pair[\u0026#34;answer\u0026#34;]} ] ) 5. online_vqa函数： 任务描述： 通过读取nuscense中的有关信息，生成带坐标信息的在线问答问题：\n2d bbox物体提问： if len(gt_bboxes_2d) \u0026gt;= 1: selected_objs = random.sample(gt_bboxes_2d, min(self.n_gen, len(gt_bboxes_2d))) for obj in selected_objs: answer = self.format_det_answer(obj[4], gt_bboxes_3d, results) sources.append( [ {\u0026#34;from\u0026#34;: \u0026#39;human\u0026#39;, \u0026#34;value\u0026#34;: f\u0026#34;Please Identity the object in the \u0026lt;{obj[5]}, {obj[0]}, {obj[1]}, {obj[2]}, {obj[3]}\u0026gt; and describe its 3D information.\u0026#34;}, {\u0026#34;from\u0026#34;: \u0026#39;gpt\u0026#39;, \u0026#34;value\u0026#34;: f\u0026#34;The object is a {answer}\u0026#34;,} ] ) 3d坐标位置周围物体提问： sources.append( [ {\u0026#34;from\u0026#34;: \u0026#39;human\u0026#39;, \u0026#34;value\u0026#34;: f\u0026#34;What objects are there near the position ({format_number(center[0].item())}, {format_number(center[1].item())})?\u0026#34;}, {\u0026#34;from\u0026#34;: \u0026#39;gpt\u0026#39;, \u0026#34;value\u0026#34;: f\u0026#34;{answer}\u0026#34;,} ] ) 车道线lane提问： for idx in index_list: if idx not in lane_objs[\u0026#39;lane_objects\u0026#39;].keys(): sources.append( [ {\u0026#34;from\u0026#34;: \u0026#39;human\u0026#39;, \u0026#34;value\u0026#34;: f\u0026#34;What objects are there on the lane {self.describe_lane([lane_objs[\u0026#39;all_lane_pts\u0026#39;][idx]])}?\u0026#34;}, {\u0026#34;from\u0026#34;: \u0026#39;gpt\u0026#39;, \u0026#34;value\u0026#34;: f\u0026#34;The objects on this lane include:\\n{answer}\u0026#34;,} ] ) ","permalink":"https://coldeye2020.github.io/tech/2024-12-15-omnidrive/","summary":"\u003ch1 id=\"一论文总览\"\u003e一.论文总览\u003c/h1\u003e\n\u003cp\u003e\u003cimg src=\"https://picgo-1301748200.cos.ap-chengdu.myqcloud.com/image-20241214214025876-20241215112414825.png\" alt=\"\" loading=\"lazy\"\u003e\u003c/p\u003e\n\u003ch2 id=\"问题背景\"\u003e问题背景：\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e现有的将多模态大模型（Multimodal Large Language Model, MLLM）引入自动驾驶领域的方法中，大多不具备3D场景理解能力，但这一能力对于自动驾驶场景而言是不可或缺的，以一个驾驶场景中常见的问题为例：\u0026ldquo;询问当前车道是否可以左转\u0026rdquo;，该问题看似在做一个简单的语义判断，本质上需要涉及到对车道与自车的几何关系判断、车道与交通灯语义、地面标识语义的匹配，要回答这些问题，需要 MLLM 模型将 2D 理解和推理能力扩展到复杂的 3D场景中。\u003c/li\u003e\n\u003cli\u003e现有 MLLM 模型按照对图像的处理方式的不同，可以分为两种流派：一种是以 Flamingo 、BLIP-2、Qwen等方法为代表，以交叉注意力机制为基础，特点是不论图像分辨率都处理成统一长度的 token 序列，而另一种是以 Vit、 BLIP、LLAVA 为代表，以自注意力机制为基础，每个 token 用于代表固定像素大小的图像局部信息，这意味着不同的分辨率图像对应变长的 token 序列。对于自动驾驶场景而言，多视角、高分辨率、连续帧（视频）是感知任务的数据特点，而变长序列代表时延、不稳定。所以作者以 BLIP-2 结构为基础，引入 Stream-PETR（作者之前的工作），构建用于自动驾驶场景的 3D MLLM。\u003c/li\u003e\n\u003cli\u003e过去的 Benchmark 大多采用简单的 QA 的形式进行评测，已有工作证明端到端自动驾驶目前 open-loop 评测方式的局限性。另外，这种评测方式驱动的方法也无法完全利用到大语言模型强大的涌现能力。对于自动驾驶场景，类似于世界模型的反事实推断能力是更符合人类思考方式和习惯的。作者希望仿照 LLAVA 的做法，提出一种高效的数据构建方式，用于构建大规模 VQA 数据集，一方面用于 MLLM 的 Instruction-tuning 训练，另一方面用于评测。\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"贡献\"\u003e贡献：\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e一种具有3d 能力的vision-language model结构，将多模态大语言模型用于自动驾驶场景中的3d场景任务；\u003c/li\u003e\n\u003cli\u003e一种基于 GPT-4o 的数据构建方法，用于生成自动驾驶场景的 VQA 问答数据，包括反事实推断形式的问答。\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch1 id=\"二方法\"\u003e二.方法\u003c/h1\u003e\n\u003cp\u003e\u003cimg src=\"https://picgo-1301748200.cos.ap-chengdu.myqcloud.com/image-20241214220821479.png\" alt=\"\" loading=\"lazy\"\u003e\u003c/p\u003e\n\u003ch2 id=\"结构总览\"\u003e结构总览：\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e图像编码器：作者采用的是基于 Clip 结构的 Eva-02 模型作为图像编码器，通过多视角图像特征提取 3d 信息，这一部分没太多需要讲的。\u003c/li\u003e\n\u003cli\u003eQ-Former3D：参考 BLIP-2 中的 Q-Former 结构，这里作者敏锐地发现了 Q-Former 结构和 Petr 系列模型结构的相似之处，将结构引入的同时也引入了 3D 目标检测任务作为辅助监督。\u003c/li\u003e\n\u003cli\u003eLLM：之前的结构的作用在于提取 3D 信息特征，最终需要对齐到 LLM 模型能够理解的特征空间，进行 VQA 问答。\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"q-former3d\"\u003eQ-Former3D:\u003c/h2\u003e\n\u003cp\u003e作为最能体现作者贡献的模块，这一部分选择 Stream-PETR 出于以下考虑：PETR 所代表的稀疏 BEV 建模方式，适用于检测任务，相比于需要构建 dense bev feature 的方法来说，需要更小的计算量，同时加快模型的推理速度，毕竟OmniDrive 的核心在于多模态大模型的能力，检测任务仅作为辅助监督，所以尽量简化降低存在感。\u003c/p\u003e","title":"OmniDrive 论文解读"},{"content":"作为这个blog 的开始 今天是 2024年的12月15 日，晴，微风，有一些冷。\n我和 coldeye 经过简单的讨论后决定在今天建立一个属于我们自己的博客。\n博客风格的选择就和我们决定的过程一样草率，我们选择了 Lil 的博客同款主题，我想主要原因可能是Lil是我看到的第一个博客，内容深度和写作风格都令人印象深刻。希望能以此为我们这个博客的榜样。\n关于这个博客的初心很简单，我们俩在偶然窥见各位技术大佬们长年累月的惊人积累后，很难不惭愧于自己当下的庸庸碌碌，感叹过去看似忙碌的日子却并没有留下什么记录。\n也许是不甘于虚度光阴，也许是为了对抗自己日渐严重的拖延症，也许是仅仅想锻炼一下自己对 markdown 语法的使用，不管怎么说，希望将这个博客作为这个时间点\u0026quot;人生的绳结\u0026quot;，以后如果回忆起这段漫长重复的日子，能被这不同平常的一日拦截下来。\n以后会定期更新一些技术博客，预期是自己某段时间的研究内容相关。由于我和 coldeye 研究方向不同，可能每篇博客的内容和风格跨度还比较大，这样其实挺好，会让这个博客的内容不太无聊。\n不管会不会有人看到这个博客，至少期待着未来的时间里自己能常来看看，体会一下我现在写这段文字的心情，直观感受到自己的成长。\n请一定要保持更新频率，别浪费了 coldeye 今天搭建博客的努力（此处应该有一个🤭）。\n最近是研三的上学期，刚结束秋招的兵荒马乱，我暂时可以算是定下了去处，coldeye 应该还会再忙一小阵。\n祝我和他还有 Lucas 先生好运。\n人生没有记录，就如同一幅无色的画。\n——托尔斯泰\n","permalink":"https://coldeye2020.github.io/think/2024-12-15-introduction/","summary":"\u003ch2 id=\"作为这个blog-的开始\"\u003e作为这个blog 的开始\u003c/h2\u003e","title":"The begining"}]