Как я мерил точность ИИ в распознавании еды: бенчмарк, LLM-as-judge и баг с варёной гречкой
- воскресенье, 14 июня 2026 г. в 00:00:04
Строю приложение для подсчёта калорий по фото. Пользователь снимает тарелку, модель определяет блюдо, считает КБЖУ. Идея не новая, но мне важно, чтобы это работало именно на русской еде — борщи, гречки, котлеты по-домашнему.
В какой-то момент стало некомфортно: я не знал, насколько модель вообще точна. «Кажется, работает нормально» — плохой ответ, если хочешь что-то улучшать. Решил померять нормально.
Расскажу, что и как мерил, что получил — и про неожиданный вывод в конце, ради которого, честно говоря, и стоило это всё делать.
Сначала кажется, что нужна одна метрика — «точность». Но это зависит от того, что считать ошибкой.
«Борщ с говядиной» вместо «борщ» — не ошибка. Пользователь это залогирует без вопросов. А «гречка» вместо «пельмени» — катастрофа: человек закроет приложение и не вернётся. Значит, нужно делить ошибки на те, что можно поправить за пару тапов, и те, после которых доверие уже не восстановить.
Я ввёл три вердикта: correct (то же блюдо, гарниры и уточнения не считаются), wrong-but-close (другое, но похожее — той же категории или кухни, нужно переименовать), wrong (принципиально другое блюдо). Неисправимая ошибка — только wrong. Если их доля ниже ~20%, доверие к продукту держится.
Второе — калибровка уверенности. Gemini возвращает confidence от 0 до 1. Я показываю предупреждение при значении ниже 0.85. Хотелось проверить, работает ли это вообще или просто висит для красоты.
Третье — правдоподобие калорий. Блюдо распознано правильно, но цифры разумные? Для каждого блюда в датасете задал референсный диапазон (борщ — 300–500 ккал на порцию, пельмени — 400–700) и проверял, попадает ли результат.
Нужны фотографии с известным ответом. Взял два открытых источника.
Food-101 от Stanford — 101 класс блюд, по тысяче фото на каждый. Оттуда взял азиатскую и европейскую еду: суши, пицца, паэлья, пад тай.
Roboflow russian-food — датасет с русскими блюдами. Небольшой, но есть борщ, пельмени, оливье, блины. Единственная проблема — классы там на английском, пришлось написать маппинг labels_map.json с переводами.
Скрипт setup.py скачивает оба источника и генерирует manifest.csv:
file,cuisine,source,truth_dish,ref_kcal_low,ref_kcal_high borscht_001.jpg,RU,roboflow,борщ,300,500 pelmeni_002.jpg,RU,roboflow,пельмени,400,700 sushi_003.jpg,ASIA,food101,суши,200,400 pizza_004.jpg,EU,food101,пицца,600,900
Итого — 66 фотографий: RU, ASIA, EU. Немного, но для первой проверки гипотезы хватает.
Два прохода.
Первый — прогоняем каждую фотографию через vision.Provider.Recognize(), это Gemini 2.5 Flash через OpenRouter. Возвращает название блюда, ингредиенты с весами, КБЖУ и confidence. Всё пишется в results.csv, поля verdict пока пустые.
res, err := provider.Recognize(ctx, f, mime) // res.DishName — "борщ с говядиной" // res.Confidence — 0.92 // res.Kcal — 420 // res.Ingredients — [{говядина, 100г}, {свёкла, 80г}, ...]
Второй — оценка результатов. Проверять 66 строк вручную утомительно и невоспроизводимо. Я использовал ту же модель как судью — она получает ground truth и предсказание и отвечает одним словом:
const judgePrompt = `You are grading a food-photo recogniser. Ground truth dish: "%s". The model predicted: "%s". Classify the match from the user's point of view: - correct: names the SAME core dish. Extra detail does NOT make it wrong — added garnishes ("бургер с картофелем фри" for гамбургер), descriptive qualifiers ("грибное ризотто" for ризотто), regional names ("нэм" for спринг роллы) are all still correct. - wrong-but-close: a DIFFERENT but related dish — same category or cuisine — that the user would have to rename. - wrong: a fundamentally different dish, or a failure to recognise the food. Reply with exactly one word: correct, wrong-but-close, or wrong.`
temperature=0, max_tokens=16. Никаких объяснений, только метка.
Использовать ту же модель как судью — спорное решение, теоретически возможен self-serving bias. Я прошёлся по части результатов вручную — явных расхождений не нашёл. Для 66 строк приемлемо; если бы датасет был в тысячи строк, взял бы отдельную модель подешевле.
Удобно ещё то, что вердикты в CSV можно поправить руками и пересчитать итоги без повторного прогона распознавания:
go run ./cmd/benchmark -rescore # переоценить вердикты go run ./cmd/benchmark -summarize # итоговый отчёт
Dish accuracy (overall): correct: 56 (84.8%) wrong-but-close: 6 ( 9.1%) wrong: 4 ( 6.1%) Recoverable (correct + close): 62 / 66 = 93.9% Unrecoverable (wrong): 4 / 66 = 6.1%
Это уже после грандинга нутриентов (об этом ниже). До него baseline был похуже — 87.9% восстановимых, 12.1% неисправимых, но сам грандинг распознавание не меняет, разница между прогонами — шум на 66 строках. В любом случае при планке в 20% неисправимых ошибок запас приличный.
По кухням картина неожиданная:
RU: 14/14 = 100.0% ASIA: 21/22 = 95.5% EU: 27/30 = 90.0%
Русскую еду модель не путает вообще — все 14 блюд распознаны как минимум близко. Хуже всего европейская. Это контринтуитивно: я ждал, что западная еда, которой в обучающих данных заведомо больше, пойдёт легче. На деле наоборот.
Если посмотреть на сами четыре провала, становится понятно почему:
На фото | Что увидела модель |
|---|---|
жареный рис | омлет с мясом и микрозеленью |
лазанья | тефтели в томатном соусе с сыром |
лазанья | жульен с грибами и сыром |
лосось на гриле | курица терияки с рисом |
Все четыре — составные или запечённые блюда, где ключевой ингредиент спрятан под сыром, соусом или корочкой. Лазанья под слоем расплавленного сыра действительно похожа на запеканку. Это не «русская/западная» граница, а «видно ингредиенты / не видно». Открытые блюда (борщ, суп, тарелка с гарниром) читаются легко; запечённые — угадываются по внешней оболочке.
Of WRONG guesses: 0 low / 4 = 0.0% warned the user 2x2: low high correct 0 56 wrong 0 4
Ни одна из четырёх неисправимых ошибок не получила предупреждения. Все четыре — high.
Но дело даже не в этом. Я полез смотреть распределение самого confidence — и оно убийственное:

49 из 66 — ровно 0.90. За весь датасет модель опустилась ниже моего порога 0.85 один раз. То есть confidence — это не вероятность, а почти константа, которую модель приклеивает к ответу «на автомате». Она не несёт информации о том, права модель или нет: коррелирует не с правильностью, а в лучшем случае с тем, насколько уверенно модель звучит.
Вывод: confidence < 0.85 как системный фильтр ошибок не работает в принципе — отсекать им нечего. Это поле можно писать в лог, но строить на нём UX («перефотографируйте, мы не уверены») нельзя — оно почти никогда не сработает, а когда модель ошибётся всерьёз, промолчит.
Разобрались с распознаванием, переходим к калориям. Здесь, как окажется, и зарыта основная собака.
Базовый показатель: из всех правильно распознанных блюд с референсным диапазоном только около половины попали в него. То есть распознать блюдо — мало; в половине случаев цифра калорий всё равно мимо.
Первая гипотеза — очевидная: LLM «придумывает» ккал/100г, потому что у неё нет структурированной БД, она интерполирует по памяти. Решение: взять справочник и подставлять реальные значения.
Я взял данные Скурихина — советский справочник состава пищевых продуктов, достаточно полный для русской кухни — дополнил частью USDA для зарубежных позиций и занёс в таблицу nutrient_reference. 226 записей.
Резолвер работает в два уровня:
// Tier 0 — точный поиск по нормализованному ключу row, err := r.store.exactByKey(ctx, normKey) if row != nil { return rowToMatch(row, 1.0), true, nil } // Tier 1 — MySQL ngram fulltext + Go similarity re-ранжирование candidates, err := r.store.fulltextShortlist(ctx, name) best, bestSim := pickBest(normKey, candidates) if bestSim >= 0.85 { return rowToMatch(best, bestSim), true, nil } // miss — оставляем LLM-значения
Normalize() убирает стоп-слова (варёный, жареный, свежий) перед сравнением, чтобы «варёная говядина» и «говядина» совпадали на Tier 0. Similarity() — bigramная схожесть (коэффициент Дайса), порог 0.85 подобран на выборке из 50 названий ингредиентов.
Покрытие вышло отличным — 96.7% ингредиентов нашлись в базе. Я был доволен.
Прогнал бенчмарк с грандингом:
Macro plausibility: LLM (ungrounded): 55.4% Grounded: 53.6%
Подождите. Покрытие 96.7%, а правдоподобие... стало немного хуже?
Вот тут я и завис на пару дней.
Когда агрегат не сходится с интуицией, помогает только одно — открыть сырые строки и посмотреть глазами. Я выписал все 26 случаев, где калории вышли за диапазон, и стал их сортировать. Проблем оказалось не одна, а несколько разных, и они тянут в разные стороны.
Сразу бросилась в глаза гречка — 783 ккал при норме 150–350. Я обрадовался: вот он, тот самый «сухой/варёный» косяк, про который все пишут. Модель назвала ингредиент «гречневая крупа», грандинг подставил калорийность сухой крупы (~330 ккал/100г), отсюда и втрое больше. Красивая история: модель не различает сырое и готовое.
Перед тем как писать про это, я открыл само фото. И завис.
На фото была сырая крупа. Буквально миска сухой гречки с веточкой петрушки — сток, а не тарелка с обедом. Модель назвала её «крупой» абсолютно правильно. 783 ккал на 300г сухой гречки — тоже примерно верно.
Кто был неправ — так это я. В манифесте этой фотографии я проставил референсный диапазон 150–350 ккал — как для варёной порции. Бенчмарк сравнил честный ответ модели с моим кривым ground truth и записал модели «провал».
Полез проверять рис — там же был «рис белый, 1650 ккал». То же самое: фото сырого риса, высыпанного из мешка. Модель права, диапазон мой неверный.
Roboflow-датасет russian-food — это набор для детекции, надёрганный из веб-картинок. Под меткой «гречка» там лежит и тарелка каши, и стоковое фото крупы в мешке. Я скриптом присвоил всем фото под одной меткой один диапазон калорий «как для порции» — и сам себе насадил мин.
Вывод отрезвляющий: я сел мерить точность модели, а нашёл баг в собственных эталонных данных. Это, пожалуй, главный практический урок всей затеи — первым делом бенчмарк ловит кривизну твоего ground truth, а не модели. Прежде чем верить любой цифре «модель ошиблась на N%», стоит открыть несколько «ошибок» глазами.
Теперь настоящий баг модели. Вычистив из головы ложную гречку, я отобрал случаи, где блюдо реально готовое, названо верно, а калории всё равно в космос:
Блюдо | Что на фото | Ингредиент (верно) | Грандинг | Норма |
|---|---|---|---|---|
сосиски | тарелка сосисок | «сосиски молочные» | 1305 ккал | 200–400 |
пельмени | порция пельменей | тесто + фарш | 1203 ккал | 350–600 |
гречка | каша с овощами на тарелке | «каша гречневая» | 588 ккал | 150–350 |
Вот гречка с овощами (ru_гречка_06) — это уже настоящая тарелка готовой каши, и модель назвала её правильно «каша». Но 588 ккал при норме до 350 — перебор. Молочные сосиски — ~170 ккал/100г, никакого сырого/варёного подвоха; 1305 ккал значит, что модель «увидела» на тарелке ~770 граммов сосисок.
Здесь имя правильное и справочник правильный — модель просто переоценила порцию в три-четыре раза. Чтобы оценить вес по фото, нужно чувство масштаба: размер тарелки, ракурс, толщина куска. Его у модели по сути нет, а грандинг тут вообще не помощник — он про ккал/100г, а ошибка в граммах.
Самое неприятное открытие. Я думал, грандинг в худшем случае нейтрален. Нет — на трёх блюдах он сломал то, что у LLM было правильно:
Блюдо | LLM | После грандинга | Норма |
|---|---|---|---|
омлет | 404 (ок) | 503 | 200–450 |
пад тай | 490 (ок) | 445 | 450–700 |
пибимпап | 453 (ок) | 408 | 450–700 |
Для составных блюд усреднённое значение из справочника бывает хуже, чем контекстная оценка модели. Модель видела конкретный омлет с конкретным количеством масла; справочник подставил «омлет вообще». В сумме грандинг исправил 2 блюда и сломал 3 — отсюда и движение метрики вниз (55.4% → 53.6%), а не вверх.
И отдельно стоит признать слабость самого измерения. «Попал в диапазон / не попал» — бинарно, и край жёсткий. Восемь из 26 «провалов» — это промахи в пределах 10% от границы:
пад тай: 445 при норме 450–700 — мимо на 5 ккал, засчитано как полный провал;
рис с маслом: 377 при норме 150–350 — перебор на 27 ккал;
гамбургер: 820 при норме 450–800.
То есть реальная доля «грубо неправильных» калорий заметно ниже, чем пугающие 46%. Если бы я считал мягче (например, штраф пропорционально промаху), картина была бы добрее. Но для honest-метрики я оставил жёсткий вариант — лучше недооценить себя, чем переоценить.
Чтобы не создавать впечатление, что модель всегда завышает: из 26 промахов 20 — завышения и 6 — занижения. Спагетти болоньезе — 157 ккал при норме 400–700, гёдза — 170 при 250–450. Реже, но бывает. Так что простой эвристикой «всегда дели на два» проблему не закрыть.
Картина по калориям получилась такая: распознавание блюда работает, справочник по ккал/100г работает (96.7% покрытия) — а итоговая калорийность всё равно мажет, и по нескольким независимым причинам сразу. Грандинг закрыл ровно одну подзадачу (точность ккал/100г), которая, как выяснилось, и не была главным источником ошибок.
Что собираюсь пробовать дальше, примерно в порядке стоимости:
Промпт на состояние ингредиента — заставить модель писать «рис отварной», а не «рис». Снимает Проблему 1, дёшево, но ненадёжно.
Кросс-чек на уровне блюда — после суммирования ингредиентов сверять итог с референсным диапазоном для типа блюда и флагировать аномалии (сосиски на 1300 — явный выброс). Бьёт по Проблеме 2.
Грандинг применять выборочно — для односоставных продуктов (крупа, мясо) доверять справочнику, для составных блюд (омлет, ризотто) оставлять оценку модели. Чинит Проблему 3.
Few-shot с весами порций — показать модели несколько фото с известными граммовками, чтобы калибровать масштаб. Самое прямое лекарство от Проблемы 2, но и самое дорогое.
Самое же главное, что дал бенчмарк, — это не цифры, а понимание, куда не надо было копать. Я потратил время на грандинг ккал/100г, уверенный, что проблема там. Бенчмарк показал, что главный источник ошибки — оценка веса порции, которую грандингом не возьмёшь. Без замера я бы ещё месяц улучшал не то.
Метрика | До грандинга | После |
|---|---|---|
Восстановимые ошибки | 87.9% | 93.9% |
Неисправимые ошибки | 12.1% | 6.1% |
Покрытие грандинга | — | 96.7% |
Макро-правдоподобие | ~52% | 53.6% |
Confidence ловит ошибки | 0 из 4 | (не менялось) |
Если коротко — три вывода:
Распознавание блюд готово к проду (93.9%), и слабое место не там, где ждёшь: хуже всего не русская еда, а запечённые блюда, где ингредиенты не видны.
Confidence от модели — почти константа, строить на нём логику предупреждений нельзя.
Калории мажут по нескольким причинам сразу, и доминирует не точность справочника, а оценка веса порции — самая дорогая для исправления часть. Грандинг чинил не то узкое место.
Если интересно поговорить про LLM-бенчмарки для domain-specific задач или про грандинг нутриентов — задавайте вопросы в комментариях.
Бенчмарк — часть реального проекта: Crumb AI — дневник питания по фото.