Двухшаговая модель «доля рынка → спрос» для прогнозирования спроса в бизнес-симуляции Global Management Challenge (pandas, scikit-learn).
Проект использует uv. Весь код запускается
только внутри виртуального окружения через uv run — окружение и сама
утилита ставятся автоматически при первом запуске:
uv syncУтилита вызывается командой gmc-forecaster и имеет три подкоманды:
forecast— прогноз спроса на следующий квартал под решениями игрока;backtest— оценка качества модели на исторических данных;cost— полная себестоимость и contribution margin по 9 ячейкам.
uv run gmc-forecaster --help
uv run gmc-forecaster forecast --help
uv run gmc-forecaster backtest --help
uv run gmc-forecaster cost --helpОсновной рабочий интерфейс: считает спрос на следующий квартал по всем 9
ячейкам (3 продукта × 3 канала) под решениями игрока. Решения читаются прямо
с первого листа 'Your decisions' файла --current — правишь свои what-if
цены/рекламу/дистрибьюторов в этом листе Excel и запускаешь прогноз (json-файл
сценария больше не нужен).
uv run gmc-forecaster forecast \
--current W115264.xls \
--train W1152*.xls W1312*.xls \
--history Hst*.xlsx \
--out forecast.csv--current— текущий отчёт (обязательно); решения берутся с его первого листа'Your decisions';--train— конкурентные отчёты для обучения логит-модели доли (обязательно);--history— файлы группы 0 для оценки сезонности (опционально);--dist-k-n,--dist-k-comm— сила эффекта числа/комиссии дистрибьюторов (опционально, дефолт0.15/0.10);--out— сохранить прогноз в CSV (опционально); коэффициенты при этом дублируются в sibling-файл<out>.coef.csv;--coeffs {key,full,none}— объём блока коэффициентов модели доли (дефолтkey; подробнее ниже).
С листа 'Your decisions' считываются (по позиционной, языконезависимой схеме):
- цены по 9 ячейкам (абсолютные, ерз);
- реклама — имидж по 3 каналам + товарная по 9 ячейкам, сворачивается в множитель рекламного бюджета уровня компании;
- число и комиссия (%) дистрибьюторов — пер-канальные (EAEU/ASEAN/INT), действуют на все 3 продукта канала. Наблюдаемы только у своей компании → дают множитель собственной привлекательности в логите доли (конкуренты не затронуты). У канала INT дистрибьюторов нет (в данных всегда 0).
База сравнения (спрос_база, Δ_рычаг) — исходные решения квартала с листа
W; правишь форму решений → колонки _сцен и Δ_рычаг показывают эффект.
Незатронутые ячейки листа наследуют текущие значения → Δ_рычаг=0. Квартал
прогноза берётся из --current (следующий за кварталом отчёта).
Колонка Δ_рычаг_% в прогнозе изолирует эффект решения: сезонность есть и в базе, и в сценарии, поэтому в отношении она сокращается.
Если у отчёта есть производственные данные (компания 1–8), таблица прогноза
дополняется колонками себестоимости: себест_полн_ед (полная себестоимость
единицы), CM_ед (contribution margin) и прибыль_ячейка
(= (цена_сцен − себест_полн_ед) × спрос_сцен) — прогноз спроса сразу
превращается в прибыль на ячейку (см. подкоманду cost ниже). В history-режиме
(без своих производственных данных) эти колонки пропускаются с предупреждением.
Модель доли (стадия 1) учитывает гетерогенность эластичности по группам (свой наклон цены усаживается к глобальному при нехватке данных — partial pooling) и исключает неактивные компании (цена 0 — не предлагают продукт, иначе забрали бы всю долю). Обоснование, бэктест и разбор — в docs/FINDINGS.md.
После таблицы прогноза печатается блок коэффициентов модели доли с их значимостью (коэффициент, ст. ошибка, t, p, звёзды) и качеством подгонки (R²/n/edf/λ), плюс строка смысла β_price (эластичность доли по цене):
Коэффициенты модели доли (стадия 1) | R²=0.909 n=504 edf=27.0 | ridge λ=1e+03 ...
признак коэф ст.ош t p знач
log(цена) -3.549 0.237 -15.00 0.000 ***
log(реклама+1) 0.024 0.058 0.41 0.679
рейтинг (звёзды) -0.166 0.122 -1.37 0.171
наклон Δ группы 11 -0.002 0.001 -2.96 0.003 ***
Смысл: β_price=-3.55 → эластичность доли по цене ≈ β_price·(1−s); при s=8% ≈ -3.25.
--coeffs key(дефолт) — константа, ключевые признаки (цена/реклама/ рейтинг) и наклоны цены по группамu_g;--coeffs full— до-выводит все фиксированные эффекты (ячейка/группа/фирма);--coeffs none— выключает блок.
Ст. ошибки — из сэндвич-ковариации штрафованного МНК (для непенализуемых
признаков совпадает с обычной OLS); p-value — нормальное приближение. Для
штрафуемых наклонов u_g значимость приблизительная.
В начале полуфинала/финала на руках лишь history-кварталы своей компании
(группа 0, компания 0, лист 'Your decisions' помечен This is a history quarter) — реального отчёта с долями рынка ещё нет: доли помечены
Not requested, а 8 «компаний» листа W в них идентичные клоны. Конкурентную
модель доли (стадию 1) по таким данным построить нельзя.
Если --current — такой history-квартал, forecast автоматически
переключается в упрощённый режим:
- бейзлайн спроса = персистенция наблюдаемого спроса по ячейке (последний квартал × сезонность, если задана);
- рычаг решений = заимствованная собственная эластичность из модели,
обученной на регулярных отчётах с долями (
--train):Δln(спрос) ≈ β_price·Δln(цена) + β_adspend·Δln(реклама+1) + ln(dist_mult), демпфируется--lever-k.
uv run gmc-forecaster forecast \
--current half-final/1.xlsx \
--train W1152*.xls W131*.xls W318*.xls W496*.xls--train (регулярные отчёты с долями) здесь обязателен — он даёт
коэффициенты эластичности. Правишь цены/рекламу/дистрибьюторов на листе
'Your decisions' файла --current — колонка Δ_рычаг_% показывает эффект
своих решений; колонка доля_сцен_% не заполняется (долей нет). Это грубый
перенос эластичностей из чужого контекста → низкая уверенность, о чём
forecast печатает предупреждение. Как только появится реальный отчёт своей
компании (компания 1–8 с долями), включится штатный конкурентный режим без
изменений в команде.
Прогоняет модель по всем смежным парам кварталов (Q_t → Q_{t+1}) в
--reports и сравнивает прогноз с фактом из отчёта Q_{t+1}. Сценарий не
нужен: фактические решения следующего квартала уже лежат в его отчёте, а
модель всё равно реагирует лишь на цену (по ячейкам) и рекламу (одно число
на компанию) — их берём из файла напрямую, без потерь. Прочие решения
(НИОКР, производство, per-cell/имидж-реклама) в модель не входят → их
влияние на спрос и есть измеряемая ошибка.
uv run gmc-forecaster backtest \
--reports W1152*.xls W1312*.xls \
--history Hst*.xlsx \
--out backtest.csv--reports— отчёты для оценки; пары смежных кварталов ищутся внутри каждой серии (одинаковые группа+компания), обязательно;--train— файлы для обучения модели доли (опционально, дефолт =--reports; для честного OOS передай сюда только чужие группы/кварталы);--history— файлы группы 0 для сезонности (опционально);--out— сохранить детализацию по ячейкам в CSV (опционально).
Ошибка раскладывается на две стадии (ломаются по-разному) и меряется в двух режимах подачи входов:
- стадия 1 (доля) — предсказанная доля vs фактическая (MAE, п.п.); чистый тест логита, считается и по всем 8 компаниям;
- стадия 2 (объём) —
sold_t/share_t × сезонностьvssold_{t+1}/share_{t+1}(MAPE); - сквозной спрос = доля × объём (MAPE), против бейзлайнов —
персистенция (
спрос_{t+1}=спрос_t) и сезонный наив; - режим realistic — конкуренты заморожены на Q_t, меняются только свои
цена+реклама (ровно то, что делает
forecast); режим oracle — подаётся фактическое рыночное состояние Q_{t+1} целиком (изолирует функцию доли). Разрыв между режимами = цена незнания ходов конкурентов.
Если модель не бьёт сезонный наив — это сигнал, что рычаги пока не окупают себя (см. docs/FINDINGS.md §5).
Полный прогон backtest по всем наборам данных (2017–2026) с разбивкой по группам и оговорками — в docs/BACKTEST.md.
Считает полную себестоимость (absorption) и contribution margin на
единицу по каждой из 9 ячеек (продукт × канал) для РЕШЕНИЯ, которое ты
подаёшь: объём, цены, реклама, комиссии, смены и парк станков читаются с первого
листа 'Your decisions' файла --current (parse_decisions), а не из фактов
продаж прошлого квартала. Себестоимость строится снизу вверх из нормативов
справочника GMC (сырьё 1/2/3 шт, изготовление 60/75/120 мин, машино-час 8 ерз,
амортизация 2.5 % и т.д.) и рыночных величин отчёта (спот-цена сырья, зарплаты).
Время сборки берётся с листа решений (для этой игры — не константа мануала;
сверено с фактически отработанными сборщико-часами).
uv run gmc-forecaster cost \
--current W115264.xls \
--train W11526[1-3].xls \
--out cost.csv
# опц.: продажи для базы «прогноз» из результата forecast
uv run gmc-forecaster cost --current W115264.xls --train W11526[1-3].xls \
--forecast forecast.csv--current— текущий отчёт компании 1–8 (обязательно); объём/цены/реклама/ комиссии/смены/станки — с листа'Your decisions'; производственные и стоимостные данные — с листов'Resources and products'/'Financial statements';--train,--history— кварталы своей фирмы для калибровки yield-факторов (брак, болезни, эффективность станков — EWMA) и спот-цены сырья (опционально);--forecast <csv>— результат командыforecast: добавляет колонки выручки/ прибыли на базе «прогноз» (продано =спрос_сцен, клип ≤ план). Без флага — только база «план» (продано = план поставок);--yield-halflife— полураспад EWMA yield-факторов, кв. (дефолт2);--out— сохранить смету по 9 ячейкам в CSV.
Оборотная база — план поставок (строки 23–25 листа решений), а не проданные
единицы: материал/конверсия считаются на произведённом, что убирает искажение
«продано со склада». Затраты разносятся на ячейки в два этапа: материал → по
единицам плана; конверсия (труд, эксплуатация станков, амортизация, ОТК) — по
сборщико-минутам на фактическом времени сборки; канал-затраты → к каналам;
реклама (из решений) и прочие накладные → по плановой выручке. Себест/ед и CM/ед
считаются на плановом прогоне и не зависят от базы продаж. Три yield-поправки
повышают себестоимость (брак/болезни/эффективность станков). Смены и парк станков
берутся из решений (труд-пул — фикс из отчёта, v1). Contribution margin =
цена − материал − полуфабрикат − комиссия (конверсия/накладные исключены).
Сверка двухрежимна. Предзаполненный лист (решения = исполненным) → смета
сходится с COGS + накладные отчёта в пределах inventory-шума (COGS считает
проданное, смета — произведённое). Отредактированный лист (what-if) → форвард-
оценка, сверка помечается неприменимой. Для history-рампа (компания 0) данных нет
→ команда завершается с ошибкой.
Форматтер, линтер, тайп-чекер и тесты запускаются одной командой:
make validate # ruff format + ruff check + mypy --strict + pytest
make test # только pytest