ДокументацияПрактические руководства (Cookbooks)Автопоиск признаков для ML

Автопоиск признаков для ML

Runs an autoresearch loop that proposes TypeSafe questions, converts free text into numeric features, and uses model errors to improve a supervised CatBoost regressor.

Запуск цикла автоматических исследований, который генерирует вопросы TypeSafe, преобразует свободный текст в числовые признаки и использует ошибки модели для улучшения регрессора CatBoost с учителем.

Вопросы TypeSafe преобразуют неструктурированный текст в числовые признаки для модели CatBoost с обучением с учителем; используйте цикл автоматических исследований (autoresearch loop) для их обнаружения.

Модели CatBoost требуется таблица чисел, а дегустационная заметка о вине таковой не является. В этом руководстве таблица строится на основе вопросов к заметке, и ни один из этих вопросов не пишется вручную. LLM предлагает вопросы, TypeSafe отвечает на них для каждой строки данных, а CatBoost обучается на полученных ответах. Далее вступает в действие цикл автоисследований (autoresearch): CatBoost сообщает, какие вопросы оказались полезными, а на каких строках он все еще ошибается; следующий вызов генератора вопросов читает этот отчет, и цикл повторяется.

В итоге вы получаете цикл, который можно натравить на собственный размеченный текст, график ошибки на отложенной выборке по раундам и таблицу признаков, которые итоговая модель использовала активнее всего.

PLAINTEXT api.wedstack.ru/v1
дегустационная заметка
    |
    v
38 ответов TypeSafe
    |-- 29 вопросов score x 2 столбца = 58
    |     ожидаемый уровень рубрики + неопределенность ответа
    `--  9 вопросов noul  x 1 столбец =  9
          вероятность true
    |
    v
67 числовых столбцов --> CatBoost --> предсказанная оценка критика
                                      RMSE на отложенной выборке: 1.77 балла

Ответ типа score превращается в два числовых столбца: средний уровень, на который указывает ответ, и степень его дисперсии (разброса вокруг среднего). Ответ типа noul — это одна вероятность, поэтому он формирует один столбец.

Датасет содержит 2 000 винных обзоров: на входе — дегустационная заметка, на выходе — оценка винного критика по шкале от 80 до 100. Метрика RMSE измеряет ошибку предсказания в баллах критика (чем больше ошибка, тем больший штраф она вносит; меньшее значение лучше). Каждое число в таблице ниже получено на 800 обзорах тестовой выборки, которую не видели ни модель, ни исследовательский цикл.

как заметка превращается в оценку RMSE
предсказание среднего значения обучающей выборки 3.09
тот же CatBoost, читающий заметку как частотности слов 2.47
прямой запрос оценки у TypeSafe (с масштабированием и сдвигом) 2.15
18 вопросов из одного запроса предложений, без цикла 1.87
38 вопросов после пяти раундов цикла 1.77

Последние две строки отражают работу цикла. Один вызов генерации признаков, еще не имеющий обратной связи об ошибках, дает RMSE 1.87. Еще четыре раунда анализа собственных худших предсказаний доводят ошибку до 1.77. Основной прирост достигается уже на первом шаге, а точный вклад последующих четырех раундов оценивается ниже.

Хотите развить этот подход или применить его к другой задаче? См. раздел [Следующие шаги](#следующие-шаги).
PYTHON EXPANDABLE THEME={NULL} api.wedstack.ru/v1

Настройка

BASH THEME={NULL} api.wedstack.ru/v1
pip install anthropic openai catboost numpy matplotlib ipython "typesafe-sdk>=0.5.7" cooksafe --extra-index-url https://pypi.typesafe.ai/

Затем установите переменные окружения TYPESAFE_API_KEY и ANTHROPIC_API_KEY. Каждый вызов API кэшируется в файле json_cache.json, поставляемом вместе с руководством, поэтому повторный запуск воспроизводит опубликованные числа без обращений к сети. Удалите файл для запуска вживую. Числа получены на моделях TypeSafe jev-1.12 и claude-sonnet-5 от 2026-08-03. В функции propose() также предусмотрена ветка для gpt-5.6-luna.

Первая ячейка кода содержит полную реализацию: вызовы API, кодирование признаков, метрики и стилизацию графиков. Она добавлена для автономного запуска файла. При первом чтении ее можно пропустить — описание рецепта начинается прямо под ней.

PYTHON THEME={NULL} api.wedstack.ru/v1
N_DEV, N_TEST = 1200, 800  # the loop reads dev labels only; test is scored once
ROUNDS = 5  # a round answers questions for all 2,000 rows: 2,000 requests
PROPOSER = "claude-sonnet-5"  # or "gpt-5.6-luna"; the cache holds the Anthropic run
EXAMPLES = 60  # dev notes the proposer reads per round, half of them its worst misses
MIN_SPREAD = 0.05  # a column this flat cannot separate anything, so it is not kept
CHANGE_TOLERANCE = 0.0  # a revision or drop has to improve dev error, not just not hurt
ENCODING = "mean_spread"  # a score answer becomes two columns: its mean and spread

split = load_split(N_DEV, N_TEST, seed=0)
NOTES, SCORES, DEV, TEST = split.notes, split.scores, split.dev, split.test

print(
    f"{len(DEV)} dev rows, {len(TEST)} held out; scores run "
    f"{SCORES.min():.0f}-{SCORES.max():.0f}, mean {SCORES.mean():.2f}, sd {SCORES.std():.2f}"
)
print(f"\none of the notes:\n{NOTES[0]}")
PLAINTEXT api.wedstack.ru/v1
1200 dev rows, 800 held out; scores run 80-98, mean 88.73, sd 3.17

one of the notes:
A Champagne that is very much wine. The structure and the richness are just right for a food wine, showing ripe acidity, flavors of plums and apricots, and balancing these primary fruits with a dense, complex structure that takes in yeast, maturity and a tight apple skin finish.

Цикл многократно считывает одни и те же 1 200 из 2 000 строк (выборка разработки dev) и сохраняет вопрос, если он помогает лучше предсказывать эти 1 200 оценок. Оценка на тех же строках измеряла бы лишь степень переобучения, поэтому остальные 800 строк отложены в сторону и оцениваются лишь единожды в самом конце.

Два типа вопросов

Предлагаемый вопрос относится к одному из двух типов, и именно тип определяет формат возвращаемого числового значения:

  • intensity превращается в Score для любых качеств, выражаемых в степенях. Пять градаций приведены ниже; результирующий столбец представляет собой средний уровень, поэтому заметка между «умеренно» и «сильно» получает промежуточное значение.
  • presence превращается в Noul для бинарных фактов вида да/нет (например, упоминается ли конкретный дефект). Столбец содержит одну вероятность.

Метод

PLAINTEXT api.wedstack.ru/v1
questions <- {}
repeat for each round:
    notes  <- round 1 ? 60 dev notes across the score range
                      : the 30 worst-predicted dev notes + the 30 best,
                        each with its score, this prediction and the last
    actions <- LLM(brief, questions, notes, importance and error so far)
    answers[q] <- TypeSafe(note, all new questions of this round) for every row
    for each added q:      keep it unless its column is flat
    for each revised q:    refit; keep the change only if dev error drops
    for each dropped q:    refit; drop it only if dev error drops
    out_of_fold <- k-fold CatBoost on the columns   # judges, and picks next round's notes

Ни один вопрос не отфильтровывается до получения ответа. Все вопросы одного раунда отправляются в одном запросе, поэтому добавление вопроса не требует дополнительных сетевых вызовов. Вопрос, встречающийся лишь в одной строке из десяти, может показаться бесполезным на 60 примерах, которые видит генератор, но при этом оказаться ценнейшим столбцом во всем датасете.

Кросс-валидация (k-fold) делит выборку разработки на k частей и предсказывает каждую часть с помощью модели, обученной на остальных частях. Эти предсказания решают три задачи: они оценивают каждое изменение и удаление, отбирают заметки для следующего раунда и показывают генератору, какие из его вопросов помогли, по величине изменения ошибок.

PYTHON THEME={NULL} api.wedstack.ru/v1
print("every intensity question is graded on these five levels:\n")
for i, level in enumerate(INTENSITY_LEVELS):
    print(f"  {i}. {level}")
print("\nevery presence question is judged true or false against these:\n")
print(f"  true:  {PRESENCE_CRITERIA['true']}")
print(f"  false: {PRESENCE_CRITERIA['false']}")
print("\nthe brief the proposer works from:\n")
print("\n".join(PROPOSER_TASK.splitlines()[:6]) + "\n  ...")
PLAINTEXT api.wedstack.ru/v1
every intensity question is graded on these five levels:

  0. Not present in this note at all
  1. Barely present - mentioned once, in passing
  2. Present at a moderate level
  3. Present strongly - the note dwells on it
  4. Dominant - the note is largely about this

every presence question is judged true or false against these:

  true:  The note states this or clearly implies it
  false: The note gives no indication of this

the brief the proposer works from:

You are designing numeric features for a gradient-boosting model that
predicts the score a wine critic gave (an integer from 80 to 100) from the tasting note alone.
The model sees nothing but the features you design.

Return up to 18 actions. Each action is one of:

  ...

Цикл автоматических исследований (Autoresearch Loop)

Функция run_loop выполняет все пять раундов и выводит сводку по каждому. Добавленный вопрос сразу включается в набор: ответы уже получены, а его полезность выяснится позже по важности признака (feature importance). Изменение формулировки или удаление вопроса исключает столбец, уже используемый моделью, поэтому такие действия сначала тестируются: модель переобучается с изменением и сохраняет его только при снижении ошибки на dev-выборке. Переобучение CatBoost не требует сетевых вызовов, поэтому пробное применение и откат изменений ничего не стоят.

PYTHON THEME={NULL} api.wedstack.ru/v1
run = run_loop(
    split, PROPOSER, ROUNDS, EXAMPLES, ENCODING, MIN_SPREAD, CHANGE_TOLERANCE
)
accepted, answers_for = run.accepted, run.answers_for
snapshots, history = run.snapshots, run.history
TEXT EXPANDABLE THEME={NULL} api.wedstack.ru/v1

Применение к собственным данным

Строка PROPOSER_TASK — единственное место в коде, где упоминается вино, а функция featurize() принимает любой список строк. Изменение этой инструкции обновляет промпт генерации вопросов, а поскольку промпт входит в ключ кэша, при следующем запуске API будет вызван заново для каждого раунда.

Количество сетевых запросов масштабируется пропорционально числу строк данных, а не числу вопросов: один запрос на строку в раунд, то есть для 100 000 строк потребуется 100 000 запросов за раунд. Изменение формулировки вопроса считается новым вопросом и требует повторного прогона по всем строкам. Увеличивайте пул параллельных воркеров с осторожностью: восьми потоков уже может быть достаточно для достижения лимитов скорости (rate limits).

Что видят вопросы

Пять отложенных обзоров, по одному из каждого квартиля диапазона оценок, сопоставлены с пятнадцатью из 38 вопросов: восемь наиболее важных вопросов типа score и семь лучших noul.

Эти пятнадцать строк отсортированы по направлению связи ответа с оценкой критика. Вопросы, ответы на которые растут вместе с оценкой, идут первыми; вопросы с обратной зависимостью расположены под разделителем. Таким образом, слева направо — от худшего обзора к лучшему — значения над разделителем растут, а под ним убывают.

PYTHON THEME={NULL} api.wedstack.ru/v1
X, labels = design(accepted, answers_for, ENCODING)
column_importances = importances(X[DEV], SCORES[DEV])
# an encoding gives a feature more than one column, so add a feature's columns back up
feature_importances = importance_per_feature(accepted, labels, column_importances)
ranked = sorted(accepted, key=lambda f: -feature_importances[f["name"]])
score_questions = [f for f in ranked if f["kind"] == "intensity"][:8]
noul_questions = [f for f in ranked if f["kind"] == "presence"][:7]
# ordered by which way the answer moves with the score, so the map flips halfway down
heatmap_questions = sorted(
    score_questions + noul_questions,
    key=lambda f: -polarity(f, answers_for, split),
)
ordered_test = TEST[np.argsort(SCORES[TEST], kind="stable")]
positions = np.linspace(0, len(ordered_test) - 1, 5).round().astype(int)
review_rows = tuple(ordered_test[positions])

print("the five held-out heatmap columns:\n")
for i, row in enumerate(review_rows, 1):
    excerpt = " ".join(NOTES[row].split())
    print(f"  {i}. {SCORES[row]:.0f} points: {excerpt[:100]}...")

fig = reviews_heatmap(plt, heatmap_questions, answers_for, split, review_rows)
display(fig)
plt.close(fig)
PLAINTEXT api.wedstack.ru/v1
the five held-out heatmap columns:

  1. 80 points: Raw cherry and plum aromas are resiny and suggest wet cement. This is shearing and so jacked up with...
  2. 86 points: A slight spritz brightens the mouthfeel of this lemony wine. Aromas are a bit musky, but flavors of ...
  3. 89 points: This is a European-style Syrah, cofermented with 2% Viognier. It's soft and round, medium in body, a...
  4. 91 points: From the producer's dry-farmed estate vineyard, and supported by small amounts of Merlot and Caberne...
  5. 97 points: A thoroughly elegant, serious and yet immensely enjoyable wine that stays lively many days after ope...
output

Фактический расчет таблицы, приведенной в начале страницы. Все пять подходов оцениваются один раз на тех же 800 отложенных строках, при этом первые три не используют поиск признаков. Первый предсказывает среднее значение dev-выборки и вообще не читает текст. Второй передает заметку в CatBoost через встроенный механизм text_features, превращающий текст в частотности слов. Третий запрашивает саму оценку напрямую у TypeSafe.

Третий вариант использует один вопрос Score на строку по десяти диапазонам качества: от «неприемлемое или с дефектом» до «выдающееся». Десять — это максимальное число уровней для вопроса Score (одиннадцать вернет ошибку сервера). Уровень 0 соответствует 80 баллам, уровень 9 — 100 баллам. Простого деления шкалы на диапазоны недостаточно, поскольку вопрос не знает, как распределены оценки конкретного издания. Поэтому каждый ответ смещается на фиксированную константу сдвига, вычисленную по выборке разработки. Это смещение указано в подписи строки и является единственной информацией, извлеченной данным методом из оценок.

Коэффициент Спирмена — это ранговая корреляция (значение 1.0 означало бы, что вина отсортированы в точном порядке критика). Строка со счетчиками слов отражает встроенный механизм CatBoost, а не специально оптимизированную NLP-модель.

PYTHON THEME={NULL} api.wedstack.ru/v1
predicted = fit_predict(X, split)
text_predicted = fit_predict_text(split)

# ask TypeSafe for the score itself, one request per row
with ThreadPoolExecutor(max_workers=8) as pool:
    direct = list(pool.map(lambda note: ask_score(TYPESAFE_MODEL, note), NOTES))
asked = np.array([d["expected"] for d in direct])
shift = float(SCORES[DEV].mean() - asked[DEV].mean())  # one number, from the dev labels

# what one proposal call gets you, before any feedback: the set round 1 ended with
first_round, _ = design(snapshots[0], answers_for, ENCODING)

print(f"{'arm':<46}{'RMSE':>7}{'spearman':>10}")
for label, p in (
    ("predict the mean of the dev rows", np.full(len(TEST), SCORES[DEV].mean())),
    ("the note as word counts, same CatBoost", text_predicted),
    (f"ask for the score itself, shifted {shift:+.2f}", asked[TEST] + shift),
    (
        f"{len(snapshots[0])} questions from round 1, no loop",
        fit_predict(first_round, split),
    ),
    (f"{len(accepted)} questions after all {ROUNDS} rounds", predicted),
):
    print(f"{label:<46}{rmse(SCORES[TEST], p):>7.3f}{spearman(SCORES[TEST], p):>10.3f}")
PLAINTEXT api.wedstack.ru/v1
arm                                              RMSE  spearman
predict the mean of the dev rows                3.088    -0.014
the note as word counts, same CatBoost          2.466     0.605
ask for the score itself, shifted -1.71         2.145     0.761
18 questions from round 1, no loop              1.869     0.778
38 questions after all 5 rounds                 1.772     0.799

Помогли ли раунды автоисследований?

Обе линии отображают ошибку набора вопросов в конце каждого раунда, начиная с первого предложения. Пунктирная линия — ошибка кросс-валидации на dev-выборке, на основе которой принимались решения об утверждении и отклонении вопросов. Сплошная линия показывает ошибку того же набора вопросов на отложенной выборке, которую цикл никогда не видел. Каждая точка — состояние набора на момент завершения раунда, поэтому раунд, в котором вопросы только редактировались или удалялись, также двигает обе линии.

Шкала графика очень компактная: весь диапазон укладывается в одну пятую балла, а все базовые варианты из таблицы выше находятся далеко за верхней границей. Линия dev проходит выше тестовой линии на всем протяжении из-за размера обучающей выборки: каждый фолд обучается на 4/5 dev-выборки, тогда как тестовая модель обучается на всех 1 200 строках. Линии движутся синхронно — метрика dev, по которой ориентируется цикл, надежно отражает качество на невидимых данных. Доверительный интервал в заголовке получен ресэмплингом (бутстрапом) отложенных строк и подтверждает, что улучшение от 1 к 5 раунду статистически значимо на фоне шума 800 строк.

PYTHON THEME={NULL} api.wedstack.ru/v1
curve, per_round = [], []
for features in snapshots:
    X_round, _ = design(features, answers_for, ENCODING)
    per_round.append(fit_predict(X_round, split))
    curve.append((len(features), rmse(SCORES[TEST], per_round[-1])))

# the same held-out rows resampled 2,000 times, both arms scored on each resample
gain = paired_gain(SCORES[TEST], per_round[0], per_round[-1])
print(
    f"round 1 -> round {ROUNDS} on the held-out rows: {gain[0]:+.3f} points, "
    f"95% CI [{gain[1]:+.3f}, {gain[2]:+.3f}]"
)

fig = rounds_chart(plt, curve, history, len(TEST), gain)
display(fig)
plt.close(fig)
PLAINTEXT api.wedstack.ru/v1
round 1 -> round 5 on the held-out rows: -0.097 points, 95% CI [-0.147, -0.050]
output

Тестовая линия снижается сильнее, чем dev-линия. Раунд 1 сформулировал вопросы без какой-либо обратной связи, а последующие четыре раунда дали дополнительное улучшение на 0.10 балла на отложенных данных (95% ДИ [-0.147, -0.050]).

В 5 раунде модель предложила четыре добавления, два переформулирования и восемь удалений, дав первый результат, не показавший улучшения на dev-выборке. О короткой заметке из 245 символов можно спросить не так уж много, и к 5 раунду баланс предложений сместился от добавления вопросов к их исключению.

PYTHON THEME={NULL} api.wedstack.ru/v1
kinds = {f["name"]: f["kind"] for f in accepted}
print("feature importance share: % of total CatBoost importance across all questions")
print(f"{'feature':<38}{'asked as':<10}{'importance share':>16}")
for name, importance_share in sorted(feature_importances.items(), key=lambda p: -p[1])[
    :12
]:
    kind = "score" if kinds[name] == "intensity" else "noul"
    print(
        f"{name[:36]:<38}{kind:<10}{importance_share:>8.1f}%  "
        f"{'#' * round(importance_share)}"
    )
counts = f"{sum(1 for k in kinds.values() if k == 'intensity')} score"
counts += f", {sum(1 for k in kinds.values() if k == 'presence')} noul"
print(f"\nthe {len(accepted)} questions the loop kept: {counts}")
top = max(feature_importances, key=feature_importances.get)
print(
    f'the question behind the top row:\n  {top}: "{owner_of(top, accepted)["question"]}"'
)
PLAINTEXT api.wedstack.ru/v1
feature importance share: % of total CatBoost importance across all questions
feature                               asked as  importance share
note_overall_tone_positivity          score         17.4%  #################
savory_food_wine_seriousness          score          8.7%  #########
positive_superlative_language         score          8.4%  ########
single_vineyard_or_prestige_signal    noul           7.2%  #######
descriptive_detail_density            score          5.7%  ######
elegance_finesse_language             score          5.0%  #####
complexity                            score          5.0%  #####
aging_potential                       score          5.0%  #####
balance_harmony                       score          2.9%  ###
drinkability_easiness                 score          2.9%  ###
critic_enthusiasm_confidence          score          2.7%  ###
flavor_distinctiveness                score          2.6%  ###

the 38 questions the loop kept: 29 score, 9 noul
the question behind the top row:
  note_overall_tone_positivity: "Setting aside specific descriptors, how positive is the overall emotional tone and word choice of the note taken as a whole (warm, admiring language throughout vs. flat, neutral, or lukewarm phrasing)?"

Колонка importance share — это важность признаков CatBoost, нормализованная так, чтобы сумма по всем 38 вопросам составляла 100%. Вопрос типа score формирует два столбца (среднее и разброс), поэтому перед выводом процентов их важности суммируются. Вопрос note_overall_tone_positivity обеспечивает 17.4% от общей важности модели. На четвертом месте находится вопрос noul: упоминание отдельного виноградника или другого маркера престижа представляет собой строгий факт да/нет, и модель спрашивала его именно в таком виде.

Следующие шаги

В этом руководстве цикл намеренно сделан компактным. Возможные пути развития:

  • Фильтрация кандидатов до оплаты вычислений. Рассматривайте сам предложенный вопрос как state и задавайте вопросы noul о нем: можно ли ответить на него по тексту, однозначны ли его критерии, применим ли он к большинству строк, будет ли он варьироваться. Отправляйте только вопросы, уверенно прошедшие все четыре проверки.
  • Отсечение коррелирующих признаков. Измеряйте корреляцию между столбцами на dev-выборке, кластеризуйте дубликаты и оставляйте один наиболее четкий или важный вопрос из каждого кластера.
  • Добавление простых бейзлайнов. Проверяйте TF-IDF, длину текста и другие структурные признаки отдельно, а затем объединяйте их с найденными признаками TypeSafe.
  • Смешивание генераторов признаков. Генерируйте кандидатов с помощью моделей разных семейств (Anthropic, OpenAI, Google Gemini, открытые модели), объединяйте и дедуплицируйте их перед отправкой в TypeSafe.
  • Сравнение моделей регрессии. Попробуйте линейную или ElasticNet-регрессию, SVM, случайный лес и калибровку вероятностей.
  • Бейзлайны на основе векторных представлений (эмбеддингов). Добавьте локальную модель вроде sentence-transformers/all-MiniLM-L6-v2 или API OpenAI text-embedding-3-small и проверьте, несут ли они дополнительную информацию сверх найденных признаков.
  • Соответствие валидации боевому сценарию. Используйте хронологическое разбиение для временных рядов, групповое разбиение для связанных данных и держите финальный тестовый набор изолированным от поиска признаков.
  • Остановка при выходе на плато. Завершайте цикл при отсутствии улучшений RMSE в течение заданного числа раундов или при исчерпании лимита вопросов.
  • Длительный поиск в режиме Goal агента. Задайте агенту явную целевую метрику, бюджет и критерий остановки, позволив ему генерировать и уточнять гипотезы на протяжении многих раундов.
  • Проверка стабильности. Повторяйте поиск признаков на разных сидах и подвыборках данных, сохраняя вопросы, стабильно полезные на всех срезах.

Открыть в Playground

Ссылка ниже содержит дегустационную заметку и все вопросы, отобранные в финальный набор цикла.

PYTHON THEME={NULL} api.wedstack.ru/v1
playground_link = make_playground_link(
    NOTES[0], feature_questions(accepted), models=[TYPESAFE_MODEL]
)
display(
    Markdown(
        f"🔗 [Open the note + questions in the TypeSafe playground]({playground_link})"
    )
)

Открыть заметку + вопросы в TypeSafe Playground →