自动研究:特征发现
梯度提升模型要的是一张数字表,而一条点评是一段话。本篇用问题把这段话变成数字:大模型(DeepSeek)负责提出问题,象信对每一条评论回答这些问题,答案变成数值列,交给 sklearn 的 HistGradientBoostingRegressor 去预测评论者给的星级。
「自动研究」指的是接下来的循环:回归模型报告它用上了哪些问题、哪些评论仍然预测得很差;下一次提问读这份报告,增加、改写或删除问题;然后再跑一轮。整个过程没有一个问题是人手写的。
数据是 500 条大众点评评论,输入评论正文,输出评论者的总评分(1–5 星)。RMSE 以「星」为单位衡量预测误差,越低越好。下表所有数字都来自那 200 条循环从未读过的 test 评论:
| 怎样把评论变成分数 | RMSE | Spearman |
|---|---|---|
| 预测 dev 的平均分 | 0.920 | — |
| TF-IDF(jieba 分词)+ 岭回归 | 0.861 | 0.485 |
| 直接问象信「几星」,再整体平移 +0.33 | 0.607 | 0.773 |
| 第 1 轮的问题,无循环 | 待补 | 待补 |
| 4 轮循环之后的问题 | 待补 | 待补 |
循环部分待大模型密钥就绪后补充
提出问题的一方是 DeepSeek。本文写作时大模型密钥尚未就绪,所以前三行基线已经真实运行(其中第三行调用了 500 次象信),后两行和下文「循环」「问题看到了什么」各节的数字留待真实运行后补充,本页暂不发布。
已经能看出的一点:在这份数据上,直接问象信一道 5 档 Score 就比词袋模型好得多(RMSE 0.607 对 0.861)。这和 TypeSafe 原文(葡萄酒评分)不同——那里 CatBoost 读词频是 2.47、直接问是 2.15,差距小得多。原因很可能是任务本身:点评星级几乎就是评论的情感倾向,而这正是象信一道题就能读出来的;葡萄酒评分则更依赖专业细节,需要许多窄问题拼起来。自动研究循环能否在 0.607 的基础上再往下压,是这一篇真正要回答的问题。
环境准备
pip install xiangxin-sdk openai scikit-learn numpy scipy jieba matplotlib
export XIANGXIN_API_KEY="sk-xx-..."
export LLM_BASE_URL="https://api.deepseek.com" LLM_API_KEY="sk-..." LLM_MODEL="deepseek-flash"所有调用缓存在 results/cache.json,重跑不会重复计费。本文基线运行于 2026-09-25,模型 xiangxin-latest(返回 xiangxin-1.0.0)。
数据
dirtycomputer/yf_dianping(Hugging Face),即 SophonPlus/ChineseNlpCorpus 整理的大众点评评论数据,约 329 万行,字段为用户、餐馆、总评分、环境 / 口味 / 服务分、时间和评论正文。仓库没有声明许可证,本文仅作研究演示。
prepare_data.py 取正文长度 40–300 字、评分非空、正文不重复的评论(约 273 万条符合),固定种子打乱后取 500 条:前 300 条是 dev(循环只读它们的标签),后 200 条是 test(只在最后评一次)。
| 星级 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|
| 条数 | 13 | 15 | 190 | 224 | 58 |
平均 3.60 星,标准差 0.83,评论平均 112 字。分布集中在 3、4 星,这让「预测平均分」这条基线本身就不算太差(0.920)。一条样本:
没想到龙德广场这里也有新侨三宝乐的面包房,吃完拉面好想吃甜的。 就钻进去买了个奶油卷,面包好硬啊,不过奶油馅很实在,4。5的价格不算便宜啊。 —— 3 星
两种问题
大模型提出的每个问题属于两种之一,种类决定返回什么数字:
intensity(程度) → 一道 Score,固定 5 档量表。它变成两列:期望档位(score),以及答案在各档上的离散程度(标准差)。presence(有无) → 一道 Noul,例如「评论是否提到排队等位」。它变成一列:成立的概率。
INTENSITY_LEVELS = [
"评论里完全没有涉及",
"略有涉及——一笔带过",
"中等程度",
"明显——评论着重写了这一点",
"压倒性——评论基本在讲这个",
]
PRESENCE_CRITERIA = {"true": "评论明确说到或清楚暗示了这一点", "false": "评论里看不出这一点"}
def feature_questions(features: list[dict]) -> dict:
qs = {}
for f in features:
if f["kind"] == "intensity":
qs[f["name"]] = Score(instructions=f["question"], criteria=INTENSITY_LEVELS)
else:
qs[f["name"]] = Noul(instructions=f["question"], criteria=PRESENCE_CRITERIA)
return qs
def encode(f, probs):
"""一道题的概率 → 数值列。Score:期望档位 + 标准差;Noul:一个概率。"""
if f["kind"] == "presence":
return [(f["name"], probs[:, 0])]
lv = np.arange(probs.shape[1])
mean = probs @ lv
var = probs @ (lv**2) - mean**2
return [(f["name"], mean), (f"{f['name']}_sd", np.sqrt(np.clip(var, 0, None)))]一轮里所有问题放进同一个请求,一条评论一次调用。所以多问一道题不增加请求数,只多几十个输入 token——这正是并行提问和扇出的思路。
方法
questions <- {}
每一轮:
评论 <- 第 1 轮:按分数均匀取 40 条 dev 评论
之后:预测最差的 20 条 + 最准的 20 条,附带本轮与上一轮的预测
actions <- 大模型(任务说明, 现有问题, 这些评论, 重要度与误差)
对全部 500 条评论,用象信回答本轮所有新问题(每条一次请求)
新增的问题:只要这一列不是几乎恒定,就收下
改写的问题:重新拟合,dev 误差下降才替换
删除的问题:重新拟合,dev 误差下降才删
out_of_fold <- dev 上 5 折 × 3 次重复的交叉验证预测提问者读到的任务说明是本篇唯一一段「知道这是点评」的文字(PROPOSER_TASK),换成你自己的标签和文本,其余代码不用改。开头几行:
你在为一个梯度提升回归模型设计数值特征。模型只根据一条大众点评评论的正文,
预测评论者给这家店的总评分(1–5 星)。模型除了你设计的特征什么都看不到。
最多返回 12 个动作(actions),每个动作是下面之一:
...回归模型固定为:
HGB = dict(max_iter=300, learning_rate=0.05, max_leaf_nodes=15, min_samples_leaf=10, random_state=0)特征重要度用 sklearn 的置换重要度(permutation_importance,以 RMSE 计),一道 Score 题的两列加回到一起,再归一化成占比,写进下一轮给提问者的反馈里。
基线:直接问几星
最省事的做法是不做特征工程,直接问象信一道 5 档 Score:
STAR_LEVELS = [
"1 星:很差,基本是在批评",
"2 星:较差,不满多于满意",
"3 星:一般,有好有坏",
"4 星:不错,总体满意",
"5 星:很好,强烈推荐",
]
r = client.system_one(
state=comment,
questions={"stars": Score(instructions="只看这条评论的内容,评论者会给这家店打几星?", criteria=STAR_LEVELS)},
)
predicted = 1 + r.answers["stars"].score # 第 0 档 = 1 星原始预测在 test 上 RMSE 为 0.669。象信的期望分整体偏低——题目里没有任何信息告诉它这批评论者习惯怎么打分——所以再加一个在 dev 上量出的平移量 +0.33(dev 真实平均分减去预测平均分),这是这条基线从标签里学到的唯一一个数,之后 RMSE 为 0.607,Spearman 秩相关 0.773。500 次调用共 87,586 个输入 token,按 ¥0.042 / 百万 token 计约 ¥0.0037。
另一条基线是经典的词袋:jieba 分词后做 TF-IDF(在 dev 上拟合,词表 1,485 个),岭回归(RidgeCV 选出 α = 3.16),test RMSE 0.861。300 条训练样本对词袋来说太少,这是它表现差的主要原因;这也正是「问题 → 特征」在小样本时的优势所在:每一列都是一个语义判断,不需要从几百条样本里学会每个词的含义。
自动研究循环
待补充
运行 main.py(有 LLM_API_KEY 时)会打印每一轮的新增 / 修改 / 删除与 dev CV RMSE,并写出 results/summary.json(开头表格的后两行、每轮 test 曲线、第 1 轮 → 最后一轮的配对自助法 95% 置信区间、特征重要度表、最终保留的问题)和 results/rounds.png。这些内容将在真实运行后填入本节。
计划的调用量:DeepSeek 4 次(每轮一次提问),象信 4 × 500 = 2,000 次,加上基线的 500 次,共 2,500 次象信调用。请求数随行数增长,而不是随问题数增长:一轮里无论问 3 道题还是 30 道题,都是每条评论一次请求。一次改写也算一道新题,需要对所有行再答一遍。
讨论
- 先看最便宜的基线。 在这份数据上,一道「几星」的 Score 已经把误差从 0.920 降到 0.607。如果目标只是一个分数,这可能就够了;特征发现的价值在于它给出可解释的列(口味、服务、价格、排队……各占多少),以及在单一问题已经饱和时继续往下压误差。
- dev 与 test 分开。 循环反复读同样的 300 条 dev 评论,并按它们上面的误差决定去留;如果在同一批评论上评分,量到的主要是循环对它们的拟合程度。所以 200 条 test 只在最后评一次。
- 样本量小。 200 条 test 上 RMSE 的抽样波动不小,所以循环前后的差异会配上配对自助法的置信区间一起报告。
- 点评星级的上限。 同一段文字,不同用户打 3 星还是 4 星本就有很大随意性;这部分误差任何只读正文的模型都消除不了。
下一步
- 在花钱回答之前先筛问题:把候选问题本身当作 state,问几道 Noul——能否从评论原文判断、在不同评论之间会不会有差异——只发送通过的。
- 把基线的「几星」Score 和 TF-IDF 作为额外列并入,看发现的问题还能补充多少。
- 换一个回归器(弹性网、随机森林)检查发现的特征是否只对梯度提升有用。
- 用多个随机种子或不同数据切片重复发现过程,只保留稳定有用的问题。
完整代码
https://github.com/xiangxinai/xiangxin-cookbooks/tree/main/autoresearch_feature_discovery
prepare_data.py:从 yf_dianping 抽 500 条评论(data/reviews.jsonl)main.py:基线、自动研究循环、评估与作图;所有调用缓存在results/cache.jsonresults/baselines.json:本页基线数字的原始输出

