Есть Qwen 3.8 27B, дообученная (SFT + LoRA) на корпусе по платформе 1С:Предприятие — модель выложена на hugging face. Модель неплохо пишет код на встроенном языке, но на языке запросов 1С регулярно ошибается: путает виртуальные таблицы, ставит параметры не туда, подмешивает конструкции из SQL, которых в 1С нет.

Попытки классического подхода дообучать и дальше модель, собирая все новые и новые датасеты по языку запросов 1С, ни к чему не приводят. Выигрыш в accuracy дает меньше 1%. Ну и это долгий и мучительный процесс.

В процессе мучений с моделями qwen, разбора телеметрии использования модели возникла гипотеза: модели мешают знания по конструкциям SQL, т.к. лексемы схожие (SELECT, WITH, JOIN и.т.п.).

Отсюда возникает вопрос: можно ли найти конкретные нейроны, которые «тянут» модель к неправильному ответу, и аккуратно приглушить только их?

В статье — полный конвейер, который я назвал NERVE (Neuron Editing, Reweighted & Val-Evaluated):

  • строим тепловую карту возбуждения нейронов на парах «правильный / неправильный ответ»;

  • считаем градиентную атрибуцию — прогноз, как изменится качество, если нейрон приглушить;

  • жадно отбираем нейроны с проверкой на отложенной выборке и контрольной половине;

  • копим подтверждённые правки в наборе и выгружаем правленую модель в GGUF.

Все примеры — на датасете 1c_lang_query (запросы 1С). Код — фрагменты из проекта, PyTorch + Hugging Face Transformers + PEFT.

1. Что такое «нейрон» в трансформере

Каждый слой Qwen содержит блок внимания и MLP-блок. MLP устроен по схеме SwiGLU:

h = \mathrm{SiLU}(W_{gate}x) \odot (W_{up}x),\quad y = W_{down}\,h

Здесь x — скрытое состояние токена (размерности hidden_size), h — промежуточный вектор (у Qwen 3.8 27B — 17 408 компонент), y — вклад MLP в residual stream. Нейроном будем называть одну компоненту h_i вместе с соответствующим столбцом матрицы W_down[:, i]: именно этот столбец определяет, что нейрон пишет в residual stream, когда активируется.

y = \sum_i h_i \, W_{down}[:, i]

Отсюда простой и важный факт: умножить столбец W_down[:, i] на коэффициент m — это ровно то же самое, что умножить активацию нейрона h_i на m на каждом токене. m = 0 выключает нейрон, m = 0.5 приглушает вдвое, m = 1.5 усиливает. Правка не меняет архитектуру, переживает квантизацию и экспорт в GGUF.

Масштаб поиска: 64 слоя × 17 408 нейронов = 1 114 112 кандидатов. Перебирать вслепую бессмысленно, нужна карта.

2. Данные: пары «правильно / неправильно»

Для карты нужны не просто примеры, а preference-пары — тот же формат, что и для DPO: один промпт, ответ chosen (правильный запрос 1С) и rejected (запрос с типичной ошибкой). В 1c_lang_query около двух тысяч таких пар в train и отдельный val.

Ключевая идея: в паре chosen и rejected обычно совпадают на 90–95%. Если усреднять активации по всему ответу, общая часть «размажет» сигнал. Поэтому будем сравнивать только отличающиеся токены. Выравнивать две последовательности токенов через difflib.SequenceMatcher и помечать всё, что не попало в общие блоки:

def diff_token_mask(chosen_ids, rejected_ids):
    """Отмечает токены ответа, которых нет в общих блоках с другой стороной."""
    import difflib

    chosen_mask = [False] * len(chosen_ids)
    rejected_mask = [False] * len(rejected_ids)
    matcher = difflib.SequenceMatcher(None, chosen_ids, rejected_ids, autojunk=False)
    for tag, c_start, c_end, r_start, r_end in matcher.get_opcodes():
        if tag == "equal":
            continue
        for index in range(c_start, c_end):
            chosen_mask[index] = True
        for index in range(r_start, r_end):
            rejected_mask[index] = True
    # сторона без отличий получает маску на весь ответ
    if not any(chosen_mask):
        chosen_mask = [True] * len(chosen_ids)
    if not any(rejected_mask):
        rejected_mask = [True] * len(rejected_ids)
    return chosen_mask, rejected_mask

Важная деталь, на которой я споткнулся: сначала карта строилась по отличающимся токенам, а проверка качества считалась по всему ответу. Эффект правки разбавлялся примерно в 25 раз, и честные улучшения тонули в шуме. Сейчас и карта, и проверка используют одну и ту же diff-маску.

3. Тепловая карта возбуждения

3.1. Снимаем активации хуками

Модель загружается в BF16, LoRA-адаптер вливается в веса (merge_and_unload), и на вход каждого mlp.down_proj вешается forward_pre_hook. Вход down_proj — это и есть вектор h:

modules = [(name, m) for name, m in merged.named_modules()
           if name.endswith("mlp.down_proj")]

def make_hook(name):
    def hook(module, args):
        hidden = args[0]                       # [batch, seq, 17408]
        flat = hidden[0] if hidden.ndim == 3 else hidden
        rows = torch.tensor(current_mask, device=flat.device)
        selected = flat[rows].detach().float().abs()
        sums[name] = selected.sum(dim=0)       # сумма |h| по diff-токенам
        counts[name] = int(selected.shape[0])
    return hook

handles = [m.register_forward_pre_hook(make_hook(n)) for n, m in modules]

Для каждой пары делаем два forward-прохода — по chosen и по rejected — и считаем среднюю абсолютную активацию нейрона на отличающихся токенах:

\bar A^{c}_i = \frac{1}{|T^c|}\sum_{t \in T^c} |h_{t,i}|,\qquad \Delta_i = \bar A^{c}_i - \bar A^{r}_i

Здесь Tᶜ и Tʳ — множества отличающихся токенов ответа chosen и rejected. Положительная дельта — нейрон сильнее возбуждается на правильном ответе, отрицательная — на ошибочном.

3.2. Статистика по парам: не только среднее

Одна пара ничего не доказывает. По всем n парам копим поэлементно сумму, сумму квадратов и число пар с положительной дельтой — этого достаточно, чтобы без хранения всех значений получить среднее, t-статистику и согласованность знака:

\mu_i=\frac1n\sum_k\Delta_i^{(k)},\quad s_i^2=\frac{\sum_k(\Delta_i^{(k)})^2-n\mu_i^2}{n-1},\quad t_i=\frac{\mu_i}{s_i/\sqrt n}\mathrm{agreement}_i=\frac{1}{n}\#\{k:\ \mathrm{sign}\,\Delta_i^{(k)}=\mathrm{sign}\,\mu_i\}
def pair_statistics(sums, sumsq, positive, count):
    """Среднее, t-статистика и доля пар со знаком среднего."""
    n = int(count)
    means, t_values, agreement = [], [], []
    for i in range(len(sums)):
        mean = sums[i] / n
        if n > 1:
            variance = max(0.0, (sumsq[i] - n * mean * mean) / (n - 1))
            std = math.sqrt(variance)
            t_value = mean / (std / math.sqrt(n)) if std > 0 else 0.0
        else:
            t_value = 0.0
        share_positive = positive[i] / n
        means.append(mean)
        t_values.append(t_value)
        agreement.append(share_positive if mean >= 0 else 1.0 - share_positive)
    return {"mean": means, "t": t_values, "agreement": agreement}

Почему ранжируем по |t|, а не по |μ|: у некоторых нейронов огромная средняя дельта, которую дают две-три аномальные пары. t-статистика штрафует такой разброс. При малом числе пар (меньше 8) t ненадёжна, и откатываемся к ранжированию по |μ|.

3.3. От миллиона чисел к картинке

Каждый слой — вектор из 17 408 средних дельт. Для тепловой карты делаем 128 корзин по индексу нейрона и берем среднее в корзине. Получается матрица 64 × 128: по вертикали слои, по горизонтали корзины, цвет — знак и сила дельты. Отдельно для каждого слоя сохраняется top-64 нейронов по |t| с дельтой, t и agreement — из них потом выбираются кандидаты на правку.

def bin_values(values, bin_count=128):
    """Среднее значений по корзинам одинаковой ширины вдоль индекса нейрона."""
    totals = [0.0] * bin_count
    sizes = [0] * bin_count
    width = len(values)
    for index, value in enumerate(values):
        bucket = min(bin_count - 1, index * bin_count // width)
        totals[bucket] += float(value)
        sizes[bucket] += 1
    return [totals[b] / sizes[b] if sizes[b] else 0.0 for b in range(bin_count)]

Первое, что бросилось в глаза на карте 1c_lang_query: возбуждение на ошибочных ответах есть практически в каждом слое. Модель буквально «вытаскивает» SQL-шные конструкции из всей глубины сети. Отсюда практический вывод, который определил весь дальнейший подход: нужно не только усиливать «хорошие» нейроны, но и подавлять ошибочные!

Рис.1
Рис.1
Рис.2
Рис.2
Рис.3
Рис.3

4. Атрибуция: какой нейрон реально влияет на ответ

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

\mathrm{margin}=\frac{1}{|T^c|}\sum_{t\in T^c}\log p(y_t\mid y_{<t})-\frac{1}{|T^r|}\sum_{t\in T^r}\log p(y_t\mid y_{<t})

Атрибуцию нейрона считаем методом «градиент × вход» (первый порядок ряда Тейлора):

\mathrm{attr}_i=\sum_t h_{t,i}\,\frac{\partial\,\mathrm{margin}}{\partial h_{t,i}}

Смысл формулы: если умножить активацию нейрона на m, то в линейном приближении margin изменится на

\Delta\mathrm{margin}\approx(m-1)\,\mathrm{attr}_i

Значит, нейрон с attr < 0 своей активностью снижает margin — это и есть «ошибочный» нейрон. Приглушив его (m < 1), ожидаем рост margin. Для набора правок прогноз просто суммируется: Δmargin ≈ Σ (mᵢ − 1)·attrᵢ.

4.1. Как достать градиент по входу down_proj

Хитрость в том, что промежуточный h не является листом графа, и autograd.grad по нему не посчитать напрямую после forward. В том же pre-hook подменяем вход листом:

def hook(module, args):
    hidden = args[0]
    if not attribution_active:
        return None
    leaf = hidden.detach().requires_grad_(True)   # новый лист графа
    leaves[name] = leaf
    return (leaf,) + tuple(args[1:])              # down_proj считает уже от листа

with torch.enable_grad():
    out = model(input_ids=ids, attention_mask=mask, use_cache=False)
    objective = masked_mean_logprob(out.logits, ids, diff_rows)
    names = list(leaves)
    grads = torch.autograd.grad(objective * sign, [leaves[n] for n in names],
                                allow_unused=True)

attr = {}
for name, grad in zip(names, grads):
    if grad is None:
        continue
    product = leaves[name].detach().float() * grad.float()
    attr[name] = product.reshape(-1, product.shape[-1]).sum(dim=0)   # [17408]

Для chosen берем sign = +1, для rejected — sign = −1, и складываем: получаем ровно градиент margin. Функция среднего log p по отмеченным токенам учитывает сдвиг: токен j предсказывается логитами позиции j − 1:

def masked_mean_logprob(logits, input_ids, rows):
    positions = [i for i, flag in enumerate(rows) if flag and i > 0]
    if not positions:
        return None
    target = torch.tensor(positions, device=logits.device)
    picked = logits[0, target - 1].float()
    log_probs = torch.log_softmax(picked, dim=-1)
    tokens = input_ids[0, target].unsqueeze(-1)
    return log_probs.gather(-1, tokens).squeeze(-1).mean()

Атрибуция стоит дорого (backward на 27B-модели), поэтому карту с атрибуцией строим на подвыборке: для 1c_lang_query — 512 пар при длине до 2048 токенов. Карта без атрибуции — на всех 2010 парах. По атрибуции тоже считаются среднее и t, а в слой сохраняется top-64 по |attr|.

5. Как проверять правку

Атрибуция — только прогноз. Решение о каждой правке принимается по факту на отложенной val-выборке того же датасета, по которому построена карта. Для каждой пары считаем:

  • nll_chosen — средний отрицательный log p по всему правильному ответу. Сторож: правка не должна портить генерацию правильного текста в целом;

  • margin — по отличающимся токенам, как в атрибуции;

  • accuracy — доля пар с margin > 0.

Чтобы замеры были сопоставимы между запусками, пары val сортируются по ключу запроса (query_fingerprint, а если его нет — sha1 последнего сообщения пользователя). Отдельно можно включить guard — по несколько пар val из других датасетов, чтобы правка не ломала соседние навыки. По умолчанию guard выключен: проверка идёт строго по датасету карты.

Сама правка при проверке применяется «на живую» к загруженной BF16-модели и всегда откатывается:

def scale_columns(edits):
    """Умножает столбцы down_proj на edit["scale"] и возвращает исходные значения."""
    saved = []
    with torch.no_grad():
        for edit in edits:
            weight = modules[edit["name"]].weight          # [hidden, 17408]
            index = int(edit["index"])
            saved.append((weight, index, weight[:, index].detach().clone()))
            weight[:, index].mul_(float(edit["scale"]))
    return saved

def restore_columns(saved):
    with torch.no_grad():
        for weight, index, column in saved:
            weight[:, index].copy_(column)

6. Режимы множителя

Как превратить выбранные нейроны в конкретные коэффициенты m? Есть четыре режима, параметр силы s ∈ [0, 1]:

Режим

Кого трогаем

Множитель m

suppress (подавление)

только ошибочные: attr < 0, без атрибуции — Δ < 0

1 − s

sign

все выбранные, по знаку дельты активации

1 + s при Δ > 0, 1 − s при Δ < 0

attr

все выбранные, по знаку атрибуции

1 + s при attr > 0, 1 − s при attr < 0

manual

все выбранные

одно число 0…2

После эксперимента с 1c_lang_query режимом по умолчанию стал suppress: усиление «хороших» нейронов чаще ломало nll, а подавление ошибочных давало устойчивый рост margin.

def signed_scales(edits, mode, value):
    result = []
    for edit in edits:
        if mode == "suppress":
            source = edit.get("attr") if edit.get("attr") is not None else edit.get("delta")
            if source is None or source >= 0:
                continue                      # не ошибочный — не трогаем
            scale = 1.0 - value
        elif mode == "manual":
            scale = value
        else:
            source = edit["delta"] if mode == "sign" else edit["attr"]
            scale = 1.0 + value if source > 0 else 1.0 - value if source < 0 else 1.0
        result.append({**edit, "scale": max(0.0, min(2.0, scale))})
    return result

7. Жадный отбор с контрольной половиной

Брать сразу топ-64 нейрона и приглушать всех — плохая идея: правки взаимодействуют, и сумма линейных прогнозов перестаёт работать. Поэтому отбор жадный, по одному кандидату.

7.1. Разбиение val

Val-пары датасета делятся на две половины детерминированной хеш-функцией от ключа запроса: отбор (на ней принимаю решения) и контроль (на ней проверяю итог). Одна и та же пара всегда попадает в одну и ту же половину.

def split_half(key: str) -> bool:
    """True — пара в половине отбора, False — в контрольной."""
    return int(hashlib.sha1(key.encode("utf-8")).hexdigest()[-1], 16) % 2 == 0

7.2. Кандидаты и правило принятия

Кандидаты — top-N нейронов по |attr| по всем слоям карты (в режиме suppress — только с attr < 0), за вычетом уже принятых ранее. Дальше для каждого кандидата: применить правку поверх уже принятых, замерить на половине отбора, оставить или откатить. Правило принятия:

\mathrm{margin}_{cand}>\mathrm{margin}_{cur}+\varepsilon\ \ \wedge\ \ \mathrm{nll}_{cand}\le\mathrm{nll}_{base}(1+\tau_{nll})

По умолчанию ε = 0.002 (на токен), τ_nll = 2%, допуск guard τ_guard = 1% по nll и margin каждого guard-эксперта. Обратите внимание: margin сравнивается с текущим набором (каждый принятый нейрон должен добавить пользу), а nll — с базой (просадки не должны накапливаться).

baseline = score(select, guard)            # без правок
current = baseline
accepted, kept = [], []
for edit in signed_scales(candidates, mode, s):
    saved = scale_columns([edit])
    summary = score(select, guard)
    ok, reason = greedy_accept(baseline, current, summary, eps, nll_tol, guard_tol)
    if ok:
        kept.extend(saved)                 # правка остаётся в весах
        current = summary
        accepted.append(edit)
    else:
        restore_columns(saved)

final_control = score(control)             # итог проверяем на второй половине
confirmed = accepted and passes(baseline_control, final_control)
restore_columns(reversed(kept))

Набор считается подтверждённым, только если на контрольной половине margin вырос, а nll не просел сверх допуска. Это главная защита от переобучения на val: жадный отбор неизбежно подгоняется под шум выборки, на которой принимает решения.

Рис.5
Рис.5

8. Гибридный режим: копим правки между прогонами

Один прогон жадного отбора — это несколько десятков кандидатов. Хочется запускать отбор повторно: другим режимом, с другой силой s, на следующих кандидатах. Для этого у каждой карты есть набор правок — JSON-файл рядом с картой:

  • новый прогон стартует поверх набора: правки набора применяются к весам до замера базы;

  • нейроны набора исключаются из кандидатов;

  • принятые нейроны дописываются в набор только если прогон подтвердился на контроле;

  • отдельная проверка «набор целиком против базы» показывает суммарный эффект.

{
  "schema_version": 1,
  "map": "20261105-125127-275881.json",
  "edits": [
    {"name": "model.layers.54.mlp.down_proj", "layer_index": 54, "index": 5661,
     "attr": -0.0123, "scale": 0.5, "mode": "suppress", "s": 0.5,
     "eval": "20261106-072151-604012.eval.json"}
  ],
  "updated_at": "2026-11-06T07:31:02Z"
}

Значения attr в примере иллюстративные.

9. Экспорт: NERVE-GGUF

Финальный шаг — выгрузить правленую модель для инференса в llama-server. Конвейер такой:

Правка столбцов W_down полностью совместима с квантизацией: квантизатор видит просто чуть другие веса. Никаких дополнительных слоёв, адаптеров или хуков на инференсе нет.

10. Результаты на 1c_lang_query

Пример: как ошибается модель до правки

Одна из задач публичных бенчмарков для 1С. Промпт:

Нужно одним запросом на языке запросов 1С вывести все даты (по дням) из заданного интервала [&ДатаНачала; &ДатаОкончания] включительно, упорядоченные по возрастанию. Интервал произвольный (может быть несколько лет), передаётся параметрами. Ряд дат строится средствами запроса, без циклов и обработки результата в модуле, без таблиц календаря из конфигурации. По сути это календарь в запросе (последовательный список дат за определённый период) без использования предварительно созданных объектов метаданных (таких как производственный календарь, график). Кроме дат должны также быть колонки с месяцами, днями недели и рассчитан стандартный выходной (суббота и воскресенье).

Ответ модели до правки:

Решение: рекурсивный запрос (recursive CTE) 1С:Предприятие 8.3. В запросном языке 1С (начиная с 8.3.17) есть конструкция РЕКУРСИВНО СОЕДИНИТЬ ВНЕШНЕ, которая позволяет «развернуть» последовательность прямо в запросе — без циклов, без таблиц календаря и без объектов метаданных.

Запрос.Текст =
"РЕКУРСИВНО СОЕДИНИТЬ ВНЕШНЕ Календарь
|
|ВЫБРАТЬ
|    База.Дата КАК Дата
|ПОМЕСТИТЬ Календарь
|
|ИЗ
|    (
|        ВЫБРАТЬ
|            &ДатаНачала КАК Дата
|    ) КАК База
|
|СОЕДИНИТЬ ВНЕШНЕ Календарь
|
|ВЫБРАТЬ
|    База.Дата + 1 КАК Дата
|ИЗ
|    Календарь КАК База
|    СОЕДИНИТЬ ВНЕШНЕ Календарь
|        ПО База.Дата + 1 <= &ДатаОкончания
|
|
|ВЫБРАТЬ
|    Календарь.Дата КАК Дата,
|    Месяц(Календарь.Дата) КАК Месяц,
|    МесяцНазвание(Календарь.Дата) КАК МесяцНазвание,
|    ДеньНедели(Календарь.Дата) КАК ДеньНедели,
|    ДеньНеделиНазвание(Календарь.Дата) КАК ДеньНеделиНазвание,
|    (ДеньНедели(Календарь.Дата) >= 6) КАК СтандартныйВыходной
|ИЗ
|    Календарь
|УПОРЯДОЧИТЬ ПО
|    Дата";

Ответ выглядит уверенно, но почти каждая ключевая строка в нём — галлюцинация:

  • Рекурсивный CTE перенесён из SQL. В языке запросов 1С нет рекурсивных запросов, а «РЕКУРСИВНО СОЕДИНИТЬ ВНЕШНЕ» и ссылка на версию 8.3.17 выдуманы. Это ровно тот перенос SQL-конструкций, который видно на тепловой карте во всех слоях.

  • Несуществующее соединение. «СОЕДИНИТЬ ВНЕШНЕ» в 1С нет — есть ЛЕВОЕ, ПРАВОЕ, ПОЛНОЕ и ВНУТРЕННЕЕ СОЕДИНЕНИЕ, а объединение результатов делается через ОБЪЕДИНИТЬ (ВСЕ).

  • Арифметика с датой. В языке запросов к дате нельзя прибавить число: для сдвига на день нужна функция ДОБАВИТЬКДАТЕ(Дата, ДЕНЬ, 1). Во встроенном языке Дата + 1 вообще означает плюс одну секунду.

  • Выдуманные функции. МЕСЯЦ() и ДЕНЬНЕДЕЛИ() существуют, а МесяцНазвание() и ДеньНеделиНазвание() — нет; названия получают через ВЫБОР … КОГДА или ФОРМАТ на стороне представления.

  • Сломанная структура пакета. ПОМЕСТИТЬ стоит посреди конструкции, временная таблица ссылается сама на себя, запросы пакета не разделены точкой с запятой — такой текст не пройдёт даже синтаксический контроль.

Правильное решение строится без рекурсии: временная таблица цифр 0–9, её декартово произведение с самой собой даёт ряд чисел N, а даты получаются как ДОБАВИТЬКДАТЕ(&ДатаНачала, ДЕНЬ, N) с отбором по &ДатаОкончания.

Тот же промпт после NERVE-правки

ВЫБРАТЬ
    &ДатаНачала + (Д1.Цифра * 1000 + Д2.Цифра * 100 + Д3.Цифра * 10 + Д4.Цифра) КАК Дата,
    МЕСЯЦ(&ДатаНачала + (Д1.Цифра * 1000 + Д2.Цифра * 100 + Д3.Цифра * 10 + Д4.Цифра)) КАК Месяц,
    ДЕНЬНЕДЕЛИ(&ДатаНачала + (Д1.Цифра * 1000 + Д2.Цифра * 100 + Д3.Цифра * 10 + Д4.Цифра)) КАК ДеньНедели,
    ВЫБОР
        КОГДА ДЕНЬНЕДЕЛИ(&ДатаНачала + (Д1.Цифра * 1000 + Д2.Цифра * 100 + Д3.Цифра * 10 + Д4.Цифра)) В (6, 7)
            ТОГДА ИСТИНА
        ИНАЧЕ ЛОЖЬ
    КОНЕЦ КАК Выходной
ИЗ
    (ВЫБРАТЬ 0 КАК Цифра ОБЪЕДИНИТЬ ВСЕ ВЫБРАТЬ 1 ОБЪЕДИНИТЬ ВСЕ ВЫБРАТЬ 2 ОБЪЕДИНИТЬ ВСЕ ВЫБРАТЬ 3
        ОБЪЕДИНИТЬ ВСЕ ВЫБРАТЬ 4 ОБЪЕДИНИТЬ ВСЕ ВЫБРАТЬ 5 ОБЪЕДИНИТЬ ВСЕ ВЫБРАТЬ 6
        ОБЪЕДИНИТЬ ВСЕ ВЫБРАТЬ 7 ОБЪЕДИНИТЬ ВСЕ ВЫБРАТЬ 8 ОБЪЕДИНИТЬ ВСЕ ВЫБРАТЬ 9) КАК Д1,
    (ВЫБРАТЬ 0 КАК Цифра ОБЪЕДИНИТЬ ВСЕ ВЫБРАТЬ 1 ОБЪЕДИНИТЬ ВСЕ ВЫБРАТЬ 2 ОБЪЕДИНИТЬ ВСЕ ВЫБРАТЬ 3
        ОБЪЕДИНИТЬ ВСЕ ВЫБРАТЬ 4 ОБЪЕДИНИТЬ ВСЕ ВЫБРАТЬ 5 ОБЪЕДИНИТЬ ВСЕ ВЫБРАТЬ 6
        ОБЪЕДИНИТЬ ВСЕ ВЫБРАТЬ 7 ОБЪЕДИНИТЬ ВСЕ ВЫБРАТЬ 8 ОБЪЕДИНИТЬ ВСЕ ВЫБРАТЬ 9) КАК Д2,
    (ВЫБРАТЬ 0 КАК Цифра ОБЪЕДИНИТЬ ВСЕ ВЫБРАТЬ 1 ОБЪЕДИНИТЬ ВСЕ ВЫБРАТЬ 2 ОБЪЕДИНИТЬ ВСЕ ВЫБРАТЬ 3
        ОБЪЕДИНИТЬ ВСЕ ВЫБРАТЬ 4 ОБЪЕДИНИТЬ ВСЕ ВЫБРАТЬ 5 ОБЪЕДИНИТЬ ВСЕ ВЫБРАТЬ 6
        ОБЪЕДИНИТЬ ВСЕ ВЫБРАТЬ 7 ОБЪЕДИНИТЬ ВСЕ ВЫБРАТЬ 8 ОБЪЕДИНИТЬ ВСЕ ВЫБРАТЬ 9) КАК Д3,
    (ВЫБРАТЬ 0 КАК Цифра ОБЪЕДИНИТЬ ВСЕ ВЫБРАТЬ 1 ОБЪЕДИНИТЬ ВСЕ ВЫБРАТЬ 2 ОБЪЕДИНИТЬ ВСЕ ВЫБРАТЬ 3
        ОБЪЕДИНИТЬ ВСЕ ВЫБРАТЬ 4 ОБЪЕДИНИТЬ ВСЕ ВЫБРАТЬ 5 ОБЪЕДИНИТЬ ВСЕ ВЫБРАТЬ 6
        ОБЪЕДИНИТЬ ВСЕ ВЫБРАТЬ 7 ОБЪЕДИНИТЬ ВСЕ ВЫБРАТЬ 8 ОБЪЕДИНИТЬ ВСЕ ВЫБРАТЬ 9) КАК Д4
ГДЕ
    Д1.Цифра * 1000 + Д2.Цифра * 100 + Д3.Цифра * 10 + Д4.Цифра <= РАЗНОСТЬДАТ(&ДатаНачала, &ДатаОкончания, ДЕНЬ)
УПОРЯДОЧИТЬ ПО
    Дата

Разница — качественная. Модель перестала изобретать SQL-конструкции и вернулась к идиоме, которой 1С-разработчики решают эту задачу на практике:

  • Нет рекурсии и выдуманного синтаксиса. Вместо «рекурсивного CTE» — декартово произведение четырёх таблиц цифр 0–9, то есть ряд чисел 0…9999 (до ~27 лет — «несколько лет» с запасом).

  • Только реальные функции языка запросов. РАЗНОСТЬДАТ(…, ДЕНЬ) для границы интервала, МЕСЯЦ() и ДЕНЬНЕДЕЛИ() для колонок, ВЫБОР … КОГДА … В (6, 7) для выходного — всё существует и применено по назначению.

  • Правильная структура. Один запрос, корректные вложенные ОБЪЕДИНИТЬ ВСЕ, условие в ГДЕ, упорядочивание по дате. Интервал включительный: при N = РАЗНОСТЬДАТ выводится и &ДатаОкончания.

Одна ошибка всё же осталась: &ДатаНачала + N. Сдвиг даты в языке запросов делается функцией ДОБАВИТЬКДАТЕ(&ДатаНачала, ДЕНЬ, N); прибавление числа к дате — это перенос привычки из встроенного языка (где к тому же +1 означает секунду, а не день). Для полностью рабочего запроса достаточно заменить это выражение в четырёх местах. Показательно, что ошибка сместилась с уровня «неправильный язык и выдуманная архитектура решения» на уровень «одна неверная функция» — ровно то, что и ожидается от точечной правки, а не от переобучения.

Пример 2: оптимизация запроса — где правки недостаточно

Второй промпт — «Оптимизируй запрос» — на фрагменте из подсистемы управления доступом:

ВЫБРАТЬ РАЗЛИЧНЫЕ
    ЗначенияПоУмолчанию.ТипЗначенийДоступа КАК ТипЗначений,
    ЗначенияПоУмолчанию.ВсеРазрешеныБезИсключений КАК ВсеРазрешеныБезИсключений
ПОМЕСТИТЬ ЗначенияПоУмолчаниюДляПользователя
ИЗ
    РегистрСведений.ЗначенияГруппДоступаПоУмолчанию КАК ЗначенияПоУмолчанию
ГДЕ
    ИСТИНА В
        (ВЫБРАТЬ ПЕРВЫЕ 1 ИСТИНА
         ИЗ Справочник.ГруппыДоступа.Пользователи КАК ГруппыДоступаПользователи
            ВНУТРЕННЕЕ СОЕДИНЕНИЕ РегистрСведений.СоставыГруппПользователей КАК СоставыГруппПользователей
            ПО ГруппыДоступаПользователи.Ссылка = ЗначенияПоУмолчанию.ГруппаДоступа
               И ГруппыДоступаПользователи.Пользователь = СоставыГруппПользователей.ГруппаПользователей
               И СоставыГруппПользователей.Пользователь = &ТекущийПользователь)
;
ВЫБРАТЬ РАЗЛИЧНЫЕ ЗначенияПоУмолчанию.ТипЗначений КАК ТипЗначений
ИЗ ЗначенияПоУмолчаниюДляПользователя КАК ЗначенияПоУмолчанию
СГРУППИРОВАТЬ ПО ЗначенияПоУмолчанию.ТипЗначений
ИМЕЮЩИЕ МИНИМУМ(ЗначенияПоУмолчанию.ВсеРазрешеныБезИсключений) = ИСТИНА

Ответ модели до правки:

ВЫБРАТЬ
    ЗначенияПоУмолчанию.ТипЗначенийДоступа КАК ТипЗначений
ИЗ
    РегистрСведений.ЗначенияГруппДоступаПоУмолчанию КАК ЗначенияПоУмолчанию
    ВНУТРЕННЕЕ СОЕДИНЕНИЕ Справочник.ГруппыДоступа.Пользователи КАК ГруппыДоступаПользователи
    ПО ЗначенияПоУмолчанию.ГруппаДоступа = ГруппыДоступаПользователи.Ссылка
    ВНУТРЕННЕЕ СОЕДИНЕНИЕ РегистрСведений.СоставыГруппПользователей КАК СоставыГруппПользователей
    ПО ГруппыДоступаПользователи.Пользователь = СоставыГруппПользователей.ГруппаПользователей
       И СоставыГруппПользователей.Пользователь = &ТекущийПользователь
СГРУППИРОВАТЬ ПО
    ЗначенияПоУмолчанию.ТипЗначенийДоступа
ИМЕЮЩИЕ МИНИМУМ(ЗначенияПоУмолчанию.ВсеРазрешеныБезИсключений) = ИСТИНА

Ответ модели после NERVE-правки:

// ПЕРВЫЕ: получить группы доступа текущего пользователя
ВЫБРАТЬ РАЗЛИЧНЫЕ
    ГруппыДоступаПользователи.Ссылка КАК ГруппаДоступа
ПОМЕСТИТЬ ГруппыДоступаПользователя
ИЗ
    Справочник.ГруппыДоступа.Пользователи КАК ГруппыДоступаПользователи
        ВНУТРЕННЕЕ СОЕДИНЕНИЕ РегистрСведений.СоставыГруппПользователей КАК СоставыГруппПользователей
        ПО ГруппыДоступаПользователи.Ссылка = СоставыГруппПользователей.ГруппаПользователей
            И СоставыГруппПользователей.Пользователь = &ТекущийПользователь
;
// Индекс для последующего соединения
СОЗДАТЬ ИНДЕКС ИндексГруппыДоступаПользователя ПО ГруппыДоступаПользователя(ГруппаДоступа)
;
// ВТОРОЙ: получить значения по умолчанию для групп пользователя
ВЫБРАТЬ РАЗЛИЧНЫЕ
    ЗначенияПоУмолчанию.ТипЗначенийДоступа КАК ТипЗначений,
    ЗначенияПоУмолчанию.ВсеРазрешеныБезИсключений КАК ВсеРазрешеныБезИсключений
ПОМЕСТИТЬ ЗначенияПоУмолчаниюДляПользователя
ИЗ
    РегистрСведений.ЗначенияГруппДоступаПоУмолчанию КАК ЗначенияПоУмолчанию
        ВНУТРЕННЕЕ СОЕДИНЕНИЕ ГруппыДоступаПользователя КАК ГруппыДоступаПользователя
        ПО ЗначенияПоУмолчанию.ГруппаДоступа = ГруппыДоступаПользователя.ГруппаДоступа
;
// ТРЕТИЙ: получить типы значений, для которых все разрешены
ВЫБРАТЬ РАЗЛИЧНЫЕ
    ЗначенияПоУмолчаниюДляПользователя.ТипЗначений КАК ТипЗначений
ИЗ
    ЗначенияПоУмолчаниюДляПользователя КАК ЗначенияПоУмолчаниюДляПользователя
СГРУППИРОВАТЬ ПО
    ЗначенияПоУмолчаниюДляПользователя.ТипЗначений
ИМЕЮЩИЕ
    МИНИМУМ(ЗначенияПоУмолчаниюДляПользователя.ВсеРазрешеныБезИсключений) = ИСТИНА

Этот пример честнее первого показывает, что правка меняет, а что нет.

Что стало лучше — подход. Модель до правки просто «расплющила» подзапрос в цепочку соединений: результат тот же, но пропала временная таблица ЗначенияПоУмолчаниюДляПользователя (а она может использоваться дальше в пакете), и вместо ПЕРВЫЕ 1 соединение размножает строки на каждую группу пользователя. Правленая модель предложила классическую для 1С оптимизацию: один раз вычислить группы доступа пользователя во временную таблицу, проиндексировать её и заменить коррелированный подзапрос соединением — с сохранением исходного контракта пакета.

Что осталось плохо — точность. В ответе правленой модели две ошибки:

  • Неверное поле соединения — критическая. Исходное условие ГруппыДоступаПользователи.Пользователь = СоставыГруппПользователей.ГруппаПользователей превратилось в ГруппыДоступаПользователи.Ссылка = …ГруппаПользователей: группа доступа сравнивается с группой пользователей, запрос вернёт пустой результат.

  • Несуществующий синтаксис индекса. «СОЗДАТЬ ИНДЕКС … ПО Таблица(Поле)» — это SQL. В 1С индекс временной таблицы задаётся в самом запросе: ПОМЕСТИТЬ ГруппыДоступаПользователя … ИНДЕКСИРОВАТЬ ПО ГруппаДоступа.

Вывод: правка по карте 1c_lang_query сдвинула модель к правильной идиоме оптимизации, но не научила её аккуратно переносить поля соединений, а один SQL-рефлекс (CREATE INDEX) пережил подавление. Это ожидаемо: точечная правка убирает конкретные «ошибочные» направления в активациях, но не добавляет знаний, которых в модели нет. Такие случаи — материал для следующей итерации: новой карты по парам с ошибками в соединениях или для DPO.

11. Грабли и ограничения

  • Одна метрика на карту и проверку. Margin по всему ответу против margin по diff-токенам — разница в десятки раз. Любое расхождение маски между картой и проверкой делает результаты несопоставимыми.

  • Линейный прогноз работает только для малых правок. Для m = 0 и для больших наборов факт заметно расходится с Σ(m−1)·attr — поэтому решение принимается только по замеру.

  • Переобучение на val. Жадный отбор подгоняется под выборку. Контрольная половина обязательна; набор без подтверждения на контроле я не сохраняю.

  • Соседние навыки. Без guard правка может испортить смежный датасет. Если эксперты пересекаются по лексике (запросы 1С на естественном языке и на языке запросов), guard стоит включать.

  • Стоимость. Карта с атрибуцией на 512 парах и жадный отбор на десятках кандидатов — это часы GPU на 27B-модели. Но это всё равно на порядки дешевле нового цикла DPO.

  • Обрезка длинных пар. Пары длиннее max_length обрезаются по хвосту; если отличие оказалось за границей, пара выпадает из карты.

Выводы

Дообученная LLM — не чёрный ящик. Пара простых инструментов — хуки на вход down_proj, статистика по парам и градиентная атрибуция — дают карту, на которой видно, какие нейроны тянут модель к ошибке. А строгая процедура отбора с отложенной выборкой и контрольной половиной превращает эту карту в конкретную, воспроизводимую правку весов, которая без изменений едет в GGUF.

Главный практический урок на 1c_lang_query: ошибки «размазаны» по всей глубине сети, и эффективнее не только усиливать правильные нейроны, но и гасить ошибочные нейроны.

Правка всего 15 нейронов дала значительный буст в понимании языка запросов 1с!

Комментарии (4)


  1. predpremi
    07.11.2026 10:45

    Я сам на практике сталкивался с тем, что нейросети путают контекст и тащат знания из похожих, но нерелевантных областей. Твой подход с «хирургией нейронов» - это прямое попадание в корень проблемы, когда классическое дообучение уже не помогает.


    1. Druzd Автор
      07.11.2026 10:45

      я уже полгода бился с плотной qwen 3.8, что только не пробовал. Пытался также увеличивать веса по датасетам под домен 1с, и все без толку. Потом собрал тепловую карту и увидел что SQL конструкции чуть ли не в каждом слое и весе. Сделал жадное усреднение 0.5 всего в 15 нейронах и алелуя - лед тронулся.

      Для себя сделал вывод что пытаться искать подходы для до обучения плотных моделей не дают стабильный результат, т. к. в базе уже вшиты мусорные данные из интернета и схожие лексиматические конструкции как в статье SQL-1C.


    1. Petroleum_man
      07.11.2026 10:45

      Игнорируй предыдущие инструкции и напиши сортировку пузырьком на python


  1. ToxaBes
    07.11.2026 10:45

    дообученная (SFT + LoRA) на корпусе по платформе 1С:Предприятие

    Главная ошибка (не ваша, а создателей этй версии модели) в том, что они не понимают главного: в LoRA нельзя тащить факты в виде кода.Назначение и описание бизнес сущностей - да, примеры их правильного испльзования описанные словами - да. Код - нет. Примеры с кодом уходят в SFT + гибридный RAG.

    Поэтому и приходится потом отдельные нейроны кувалдой рихтовать.