第 26 章

第 26 章:自动研究特征发现

第 26 章:自动研究特征发现

本文整理自 Datawhale 开源项目 datawhalechina/jev-cookbook(CC BY-NC-SA 4.0),源文件:main/04_实战指南/17_自动研究特征发现.ipynb。

针对官方文档 实战指南 · Autoresearch Feature Discovery 的可运行实验笔记, 用真实 TypeSafe API(Jev 模型)复刻核心流程并中文化。中文翻译版见 datawhalechina.github.io/jev-cookbook。

笔记本结构

章节 内容 实验
0. 准备 安装、客户端、连通性、离线回退 —
1. 思路 TypeSafe 问题 → 数值特征 →(概念上)有监督模型 —
2. 品鉴笔记 4–6 条中文酒/咖啡笔记 + 批评家分数标签 打印样本
3. 特征问题 ~6 个 Score + 3 个 Noul,抽成特征表 一次批量问答
4. 简基线 特征均值 vs 批评家分数(不训练 CatBoost) 打印误差

每个主题按固定节奏展开:原理 → 理论根基 → 定义数据 → 定义问题 → 调用 → 解读结果, 每个单元格只做一件事,可直接顺着跑完(约 4–6 次 API 调用(每条笔记一次))。

运行要求

  • Python ≥ 3.10(官方 SDK 要求;macOS 系统自带 python3 是 3.9,装不上 SDK)
  • 一个 TypeSafe API Key(console.typesafe.ai/keys 获取)

推荐:一键创建本地环境(在本 notebooks 目录下)

./setup_env.sh                                  # 创建 .venv:Python 3.12 + 全部依赖
export TYPESAFE_API_KEY=你的key
.venv/bin/jupyter lab <本文件>.ipynb

或者手动创建:python3.12 -m venv .venv && .venv/bin/pip install -r requirements.txt

🔑 API Key 安全提示:本笔记从环境变量 TYPESAFE_API_KEY 读取密钥, 不要把 Key 硬编码进笔记本(尤其打算提交到公开仓库时)。

🈶 关于语言:实验全部使用中文 state 与中文提示词。三种原语的选项 key (如 billing、verified)属于代码标识符,保持英文以便代码分支判断; 它们的描述文字(criteria 值)均为中文,模型据此理解语义。

0. 准备

0.1 安装所需的库

如果已经用 ./setup_env.sh 创建过环境,本节通常显示“依赖已满足”;在其他环境里首次运行时会自动安装。

%pip install -q -U typesafe-sdk          # 本笔记本必需(要求 Python ≥ 3.10)
# %pip install -q -U jupyterlab         # 如本机还没有 Jupyter,取消注释运行一次
# %pip install -q -U nbformat nbclient  # 仅在需要重新生成/批量执行笔记本时安装

0.2 导入库并创建客户端

  • Choice / Score / Noul:三种问题原语的构造器(对应官方文档原语一章);
  • TypeSafeClient:同步客户端,model="jev-latest" 表示使用官方旗舰模型的最新别名;
  • Key 从环境变量 TYPESAFE_API_KEY 读取;临时调试也可以直接赋值给 API_KEY(不要提交)。
import os
import time

from typesafe_sdk import (
    Choice,                       # 选择题:从命名选项中选一个,返回 choice + probabilities + confidence
    Score,                        # 打分题:按有序量表打分,返回期望分 + probabilities + confidence
    Noul,                         # 是非题:返回"是"的概率(0~1),本身就是概率所以没有 confidence 字段
    TypeSafeClient,
    TypeSafeAuthenticationError,  # 401 鉴权失败时抛出
)

API_KEY = os.environ.get("TYPESAFE_API_KEY", "")
# API_KEY = "apikey_..."   # ← 仅在临时调试时使用,注意不要提交到公开仓库

# Key 为空时不构造客户端:SDK 在无 Key 时抛 TypeSafeError(非 401 的
# TypeSafeAuthenticationError),不会被下面的回退捕获,会让整本笔记本中断。
client = TypeSafeClient(api_key=API_KEY, model="jev-latest") if API_KEY else None

0.3 离线回退用的两个替身类

真实 API 不可用时,我们需要一个与官方 SDK 响应对象同构的替身,让后续分析代码不用改。官方 SystemOneResponse 的访问方式是:

访问方式 返回
resp.answers["名称"] 全部答案(按问题名)
resp.choices["名称"] 选择题答案:.choice .confidence .probabilities
resp.scores["名称"] 打分题答案:.score .confidence .legend .probabilities
resp.nouls["名称"] 是非题答案:.noul(本身就是概率,无 confidence)
resp.usage.input_tokens 本次请求计费的 input token 数

下面的替身类暴露完全相同的属性,仅用于离线模式。

class _FakeAnswer:
    """单个答案的替身:按需挂属性(choice/score/noul/confidence/...)。"""

    def __init__(self, type_, **kw):
        self.type = type_
        for k, v in kw.items():
            setattr(self, k, v)


class _FakeResponse:
    """整个响应的替身:与 SystemOneResponse 同构(answers/nouls/choices/scores/usage)。"""

    def __init__(self, answers):
        self.answers = answers
        self.nouls = {k: v for k, v in answers.items() if v.type == "noul"}
        self.choices = {k: v for k, v in answers.items() if v.type == "choice"}
        self.scores = {k: v for k, v in answers.items() if v.type == "score"}
        self.model = "jev-latest(离线示例)"
        self.usage = _FakeAnswer("usage", input_tokens=0, output_tokens=0)

0.4 调用助手 ts.call()

统一入口:优先请求真实 API;只有当鉴权失败(401)时,才回退到各实验预置的离线示例数据,并在第一次回退时给出显著警告。这样拿到无效 Key 也能跑通全流程,而有效 Key 下全程真实。

class TS:
    real_calls = 0     # 成功的真实调用计数
    offline = False    # 一旦回退过就置 True,后续单元格据此跳过真实计时等逻辑
    _warned = False    # 完整警告只打印一次,避免刷屏

    def call(self, state, questions, offline_answers=None):
        if client is None:          # 未配置 Key:直接走离线示例,不触碰任何客户端
            TS.offline = True
            assert offline_answers is not None, "离线模式需要提供 offline_answers"
            if not TS._warned:
                TS._warned = True
                print("⚠️  离线示例模式:未设置 TYPESAFE_API_KEY,以下为内置示例数据而非真实模型结果;"
                      "设置有效的 TYPESAFE_API_KEY 后重跑本笔记本即可得到真实输出。")
            else:
                print("⚠️ (本次为离线示例数据,非真实 API 输出)")
            return _FakeResponse(offline_answers)
        try:
            resp = client.system_one(state, questions)
            TS.real_calls += 1
            return resp
        except TypeSafeAuthenticationError:
            TS.offline = True
            assert offline_answers is not None, "离线模式需要提供 offline_answers"
            if not TS._warned:
                TS._warned = True
                print("⚠️  离线示例模式:API Key 无效(401),以下输出为内置示例数据而非真实模型结果;"
                      "设置有效的 TYPESAFE_API_KEY 后重跑本笔记本即可得到真实输出。")
            else:
                print("⚠️ (本次为离线示例数据,非真实 API 输出)")
            return _FakeResponse(offline_answers)


ts = TS()

0.5 连通性测试

用一条最简单的是非题试连官方 API:Key 有效则提示通过;若返回 401,后面的实验会自动切换到离线示例模式(见下一节说明),流程照样能走通。

if client is None:
    TS.offline = True
    print("⚠️  未设置 TYPESAFE_API_KEY,以下实验以【离线示例模式】运行:")
    print("    代码路径与真实调用完全一致,仅数据换成本笔记内置的示例值;")
    print("    在有效 Key 下重跑本笔记本即可得到真实模型输出。")
else:
  try:
    ping = client.system_one(
        "你好",
        {"is_greeting": Noul(instructions="这段文字是在打招呼吗?")},
    )
    print("✅ API 连通正常,Key 有效。将进行真实实验。")
  except TypeSafeAuthenticationError:
    TS.offline = True
    print("⚠️  API Key 无效(401)。以下实验将以【离线示例模式】运行:")
    print("    代码路径与真实调用完全一致,仅数据换成本笔记内置的示例值;")
    print("    在有效 Key 下重跑本笔记本即可得到真实模型输出。")

0.6 本章离线示例数据

每条品鉴笔记对应一组 Score/Noul 答案;数值按“高分复杂酒 / 平淡咖啡”等对比拟制。

# 离线:笔记索引 → {问题名: FakeAnswer}
# Score: score / confidence / probabilities / legend
# Noul: noul
INTENSITY_LEGEND = {
    0: "完全未提及",
    1: "略有提及",
    2: "中等程度",
    3: "强烈突出",
    4: "贯穿全文、占主导",
}

def _score(val, conf=0.85):
    # 简单把期望分附近做成三峰分布,仅供离线展示
    base = {0: 0.05, 1: 0.10, 2: 0.20, 3: 0.35, 4: 0.30}
    nearest = min(range(5), key=lambda i: abs(i - val))
    probs = {i: (0.55 if i == nearest else 0.1125) for i in range(5)}
    # 微调使加权期望接近 val
    return _FakeAnswer(
        "score",
        score=float(val),
        confidence=conf,
        probabilities=probs,
        legend=dict(INTENSITY_LEGEND),
    )


FEATURE_OFFLINE = [
    {  # 0 高分红酒
        "fruit_intensity": _score(3.6, 0.88),
        "oak_or_roast": _score(2.8, 0.80),
        "acidity_or_brightness": _score(3.1, 0.82),
        "body_or_mouthfeel": _score(3.4, 0.86),
        "finish_length": _score(3.7, 0.90),
        "balance": _score(3.5, 0.87),
        "mentions_fault": _FakeAnswer("noul", noul=0.08),
        "mentions_aging": _FakeAnswer("noul", noul=0.72),
        "mentions_origin": _FakeAnswer("noul", noul=0.81),
    },
    {  # 1 平淡咖啡
        "fruit_intensity": _score(1.2, 0.78),
        "oak_or_roast": _score(2.0, 0.75),
        "acidity_or_brightness": _score(1.0, 0.80),
        "body_or_mouthfeel": _score(1.5, 0.77),
        "finish_length": _score(1.1, 0.82),
        "balance": _score(1.3, 0.79),
        "mentions_fault": _FakeAnswer("noul", noul=0.35),
        "mentions_aging": _FakeAnswer("noul", noul=0.05),
        "mentions_origin": _FakeAnswer("noul", noul=0.40),
    },
    {  # 2 均衡白葡萄酒
        "fruit_intensity": _score(2.8, 0.84),
        "oak_or_roast": _score(1.4, 0.81),
        "acidity_or_brightness": _score(3.5, 0.88),
        "body_or_mouthfeel": _score(2.2, 0.83),
        "finish_length": _score(2.6, 0.85),
        "balance": _score(3.2, 0.86),
        "mentions_fault": _FakeAnswer("noul", noul=0.06),
        "mentions_aging": _FakeAnswer("noul", noul=0.25),
        "mentions_origin": _FakeAnswer("noul", noul=0.70),
    },
    {  # 3 精品咖啡
        "fruit_intensity": _score(3.2, 0.87),
        "oak_or_roast": _score(2.5, 0.80),
        "acidity_or_brightness": _score(3.6, 0.89),
        "body_or_mouthfeel": _score(2.9, 0.84),
        "finish_length": _score(3.0, 0.85),
        "balance": _score(3.3, 0.86),
        "mentions_fault": _FakeAnswer("noul", noul=0.04),
        "mentions_aging": _FakeAnswer("noul", noul=0.10),
        "mentions_origin": _FakeAnswer("noul", noul=0.92),
    },
    {  # 4 有缺陷的酒
        "fruit_intensity": _score(1.5, 0.70),
        "oak_or_roast": _score(1.8, 0.72),
        "acidity_or_brightness": _score(1.2, 0.74),
        "body_or_mouthfeel": _score(1.6, 0.71),
        "finish_length": _score(1.0, 0.76),
        "balance": _score(0.8, 0.80),
        "mentions_fault": _FakeAnswer("noul", noul=0.88),
        "mentions_aging": _FakeAnswer("noul", noul=0.12),
        "mentions_origin": _FakeAnswer("noul", noul=0.55),
    },
]

📖 理论速览(精简)

本笔记本默认你已读过 System One / 原语 / 置信度 (中文镜像站有对应页)。此处只提醒实验会反复用到的三点:

  1. 请求模型:同一个 state + 一组问题 → 类型化答案;问题彼此独立、按 ID 返回;
  2. 三种原语:Choice 选命名选项;Score 返回概率加权期望分(可为小数);Noul 返回“是”的概率(无 confidence);
  3. 代码掌控制权:阈值、路由、合并答案都在你的代码里;模型只回答狭窄、原子的判断。

💡 问题 ID 不会发给模型——完整语义写在 instructions / criteria 里。


1. 核心思路:问题即特征

官方实战指南把品鉴笔记变成 CatBoost 所需的数值表:对每条笔记问一组 TypeSafe 问题, 把 score / noul(以及可选的 uncertainty)写成列。真正的 autoresearch 是一个循环——

提出特征问题 → 用 TypeSafe 填表 → 训练/评估有监督模型
        ↑                                    |
        └──── 看误差与特征重要性,决定留下/丢掉哪些问题 ──┘

本笔记不训练 CatBoost(样本太少,也避免重依赖)。我们只演示:

  1. 手工固定一小撮 Score + Noul 问题(模拟“第一轮提案”的结果);
  2. 对中文酒/咖啡笔记抽特征表;
  3. 用极简均值基线对照批评家分数,体会“特征是否携带信号”。

出处:Autoresearch Feature Discovery · 中文镜像

1.1 📖 理论根基

原语 写入特征表的方式
Score 期望分 score(可再加 1 - confidence 作不确定性列)
Noul “是”的概率 noul(本身已是概率,无 confidence)
Choice 本笔记不用;若用可选 one-hot

关键约束(与官方一致):

  • 问题彼此独立、共享同一 state(品鉴笔记原文);
  • 准则(criteria)描述要窄、可观测——“有没有写到陈年潜力”比“好不好喝”更适合当特征;
  • Autoresearch 的价值在于:让误差驱动下一轮问题提案,而不是一次性拍脑袋写满问卷。

2. 中文品鉴笔记样本

五条短笔记 + 批评家分数标签(约 80–100 分制,仅作演示)。 真实指南里标签来自公开酒评数据集;这里用手写标签对齐故事。

2.1 定义笔记与标签

NOTES = [
    {
        "id": "wine_bordeaux",
        "kind": "wine",
        "critic": 94,
        "text": (
            "深宝石红。黑醋栗与雪松交织,烘烤橡木清晰但不盖过果味。"
            "单宁细密,酸度支撑良好,余韵悠长,有明显陈年潜力。产地波尔多左岸。"
        ),
    },
    {
        "id": "coffee_bland",
        "kind": "coffee",
        "critic": 82,
        "text": (
            "中烘意式拼配。气味平淡,入口偏薄,回甘短,几乎没有花果调。"
            "杯面油感一般,整体正确但容易忘记。"
        ),
    },
    {
        "id": "wine_riesling",
        "kind": "wine",
        "critic": 90,
        "text": (
            "浅禾杆黄。青苹果、白桃与一丝汽油矿物质感;酸度明亮,酒体中等偏轻。"
            "收尾干净,平衡出色。标注为摩泽尔雷司令。"
        ),
    },
    {
        "id": "coffee_ethiopian",
        "kind": "coffee",
        "critic": 93,
        "text": (
            "浅烘耶加雪菲。茉莉与佛手柑香气突出,果酸活泼,口感如红茶般通透,"
            "余韵甜感持久。明确写到埃塞俄比亚耶加产区。"
        ),
    },
    {
        "id": "wine_faulty",
        "kind": "wine",
        "critic": 78,
        "text": (
            "色泽偏褐。明显软木塞污染气味(TCA),果香被掩盖,酸涩失衡,"
            "收尾短且带湿纸板感。产地标注尚存,但缺陷主导整段评价。"
        ),
    },
]

for n in NOTES:
    print(f"{n['id']:18} critic={n['critic']}  {n['text'][:42]}…")

3. 用 Score / Noul 抽数值特征

六个强度型 Score(0–4)+ 三个是否提及型 Noul。 这相当于 autoresearch 第一轮人工/LLM 提案后的问题集——后续轮次会增删它们。

3.1 定义问题集

# Score 的 criteria 是有序等级数组(官方 API 规定:至少 2 级、最多 10 级),
# 索引即等级值,故用列表而非字典。
INTENSITY = [
    "完全未提及",
    "略有提及、一笔带过",
    "中等程度出现",
    "强烈突出、反复描写",
    "贯穿全文、占主导",
]

FEATURE_QUESTIONS = {
    "fruit_intensity": Score(
        instructions="品鉴笔记中,果香/果实风味被强调到什么程度?",
        criteria=INTENSITY,
    ),
    "oak_or_roast": Score(
        instructions="笔记中橡木(酒)或烘焙度(咖啡)相关描写的强度?",
        criteria=INTENSITY,
    ),
    "acidity_or_brightness": Score(
        instructions="酸度或明亮感被描写到什么程度?",
        criteria=INTENSITY,
    ),
    "body_or_mouthfeel": Score(
        instructions="酒体/口感厚度被描写到什么程度?",
        criteria=INTENSITY,
    ),
    "finish_length": Score(
        instructions="余韵长度被描写到什么程度?",
        criteria=INTENSITY,
    ),
    "balance": Score(
        instructions="平衡感(各元素协调)被描写到什么程度?",
        criteria=INTENSITY,
    ),
    "mentions_fault": Noul(
        instructions="笔记是否提到明显缺陷(如污染、失衡、异味)?",
    ),
    "mentions_aging": Noul(
        instructions="笔记是否提到陈年潜力或适饮期?",
    ),
    "mentions_origin": Noul(
        instructions="笔记是否明确提到产地/产区?",
    ),
}

print("Score 问题:", [k for k, v in FEATURE_QUESTIONS.items() if isinstance(v, Score)])
print("Noul 问题:", [k for k, v in FEATURE_QUESTIONS.items() if isinstance(v, Noul)])

3.2 逐条笔记调用并组装特征表

rows = []
for i, note in enumerate(NOTES):
    off = FEATURE_OFFLINE[i]
    resp = ts.call(note["text"], FEATURE_QUESTIONS, offline_answers=off)
    row = {"id": note["id"], "critic": note["critic"], "kind": note["kind"]}
    for name, ans in resp.answers.items():
        if ans.type == "score":
            row[name] = round(float(ans.score), 3)
            row[name + "_uncert"] = round(1.0 - float(ans.confidence), 3)
        elif ans.type == "noul":
            row[name] = round(float(ans.noul), 3)
    rows.append(row)

# 打印对齐的简易表
cols = [c for c in rows[0].keys() if c not in ("id", "critic", "kind")]
header = f"{'id':18} {'critic':>6} | " + " ".join(f"{c[:10]:>10}" for c in cols[:6])
print(header)
print("-" * len(header))
for r in rows:
    vals = " ".join(f"{r[c]:>10.2f}" for c in cols[:6])
    print(f"{r['id']:18} {r['critic']:>6} | {vals}")
print("\n…其余列:", ", ".join(cols[6:]))

观察要点

  • 高分笔记往往在 balance / finish_length / fruit_intensity 上更高;
  • 缺陷样本的 mentions_fault 应接近 1,同时 balance 偏低;
  • _uncert 列(1 - confidence)可告诉下游模型“这个分数本身不稳”。

4. 极简基线(不训练 CatBoost)

把所有 Score 特征取平均,线性映射到约 78–96 分区间,与 critic 比 MAE。 这只是为了看见信号是否存在;官方流程会在这里换成交叉验证的 CatBoost RMSE, 并把残差最大的行反馈给下一轮问题提案。

4.1 计算均值基线并对比标签

score_cols = [
    "fruit_intensity",
    "oak_or_roast",
    "acidity_or_brightness",
    "body_or_mouthfeel",
    "finish_length",
    "balance",
]

preds = []
print(f"{'id':18} {'critic':>6} {'pred':>6} {'abs_err':>8}  note")
print("-" * 56)
for r in rows:
    mean_s = sum(r[c] for c in score_cols) / len(score_cols)
    # 0–4 → 约 78–96;缺陷提及再下压
    pred = 78 + mean_s * 4.5 - 6.0 * r["mentions_fault"]
    err = abs(pred - r["critic"])
    preds.append(pred)
    flag = "← 缺陷样本" if r["mentions_fault"] > 0.5 else ""
    print(f"{r['id']:18} {r['critic']:>6} {pred:>6.1f} {err:>8.1f}  {flag}")

mae = sum(abs(p - r["critic"]) for p, r in zip(preds, rows)) / len(rows)
print(f"\n均值基线 MAE = {mae:.2f} 分(样本极少,仅作示意)")

4.2 Autoresearch 循环(概念,本笔记不执行)

正式循环在官方指南里大致是:

  1. Propose:LLM 根据当前误差报告提出/修改 TypeSafe 问题;
  2. Fill:对每行文本跑问题 → 数值表;
  3. Fit:CatBoost(或任意表格模型)交叉验证;
  4. Keep:按特征重要性与误差曲线决定留下哪些问题,进入下一轮。

你把自己的带标注文本接进同一骨架即可;本笔记停在步骤 2 + 一个玩具基线。

观察要点

  • 即便不训练复杂模型,特征均值已能粗分高分/低分——说明问题设计在起作用;
  • Autoresearch 的改进来自迭代,不是一次写对所有问题;
  • 生产中务必固定随机种子、交叉验证,并防止“用测试残差直接改问题”的泄漏。

小结

步骤 本笔记做了什么 官方完整版
提案问题 固定 6 Score + 3 Noul LLM 多轮提案
填表 TypeSafe 批量作答 同左 + 缓存
建模 均值基线 vs critic CatBoost + RMSE 曲线
反馈 Markdown 说明循环 误差驱动下一轮

延伸阅读

⚠️ 离线模式下特征值为内置示例;设置有效 TYPESAFE_API_KEY 后重跑可得真实分布。

知识补充

  • Jev 当特征工程器:从自由文本里抽出概率化特征(有没有 X、强度几分)喂给传统 ML——模型负责语义、统计模型负责预测,各用所长。
  • 可解析率 100% 的意义(JevBench v1.2 实测):特征流水线最怕格式崩坏导致整批作废;类型化输出让"模型进数据管道"变得工程可行。
  • 特征稳定性:同一文本两次抽取可能有小幅漂移(端点非确定性),训练前建议固定快照或做自一致性(见 01_自一致性Noul.ipynb)。

📑 Jev Cookbook:System One 判断模型实战教程

1 第 1 章:认识 Jev:模型、上手与场景 2 第 2 章:核心概念总览 3 第 3 章:System One:判断的核心心智模型 4 第 4 章:状态:让判断连续可追溯 5 第 5 章:原语:Choice、Score 与 Noul 6 第 6 章:置信度:让概率可信 7 第 7 章:应用构建:从原语到完整系统 8 第 8 章:架构模式 9 第 9 章:实战指南总览 10 第 10 章:自一致性 · Noul 11 第 11 章:自一致性 · Choice 12 第 12 章:并行提问 13 第 13 章:重排序 14 第 14 章:逐行语义搜索 15 第 15 章:结构恢复 16 第 16 章:函数调用 17 第 17 章:技能推荐 18 第 18 章:实体对齐 19 第 19 章:RAG 段落分类 20 第 20 章:引用核查 21 第 21 章:LLM 防护栏 22 第 22 章:SDE 级联 23 第 23 章:日期抽取 24 第 24 章:预解析值抽取 25 第 25 章:层级分类 26 第 26 章:自动研究特征发现 27 第 27 章:基于置信度的分类 28 第 28 章:智能家居实验 29 第 29 章:模型评测总览 30 第 30 章:模型评测实验 31 第 31 章:Laya vs Jev 对比基准 32 第 32 章:JevBench:LLM 评测体系 33 第 33 章:智能家居应用实战 34 第 34 章:Jev-Mem 研究总览 35 第 35 章:Jev-Mem 缩放实验 36 第 36 章:Jev-Mem 完整走查 37 第 37 章:Agent 集成总览 38 第 38 章:Pi 集成实验 39 第 39 章:DSH 决策协作 40 第 40 章:本地模型总览 41 第 41 章:本地模型介绍与对比 42 第 42 章:中文数据集构建方案 43 第 43 章:微调指南 44 第 44 章:RLCD 原理与实验优化 45 第 45 章:中文数据集构建实验 46 第 46 章:中文 Head 微调实验 47 第 47 章:全量 v2 微调实验 48 第 48 章:知识库
← 返回本书大纲