Skip to content

GAIL и GRPO: seed= не значил ничего - #46

Open
DenisDrobyshev wants to merge 2 commits into
mainfrom
fix/gail-seeds-its-own-environment
Open

DenisDrobyshev wants to merge 2 commits into
mainfrom
fix/gail-seeds-its-own-environment

Conversation

@DenisDrobyshev

@DenisDrobyshev DenisDrobyshev commented Sep 20, 2026 •

Copy link
Copy Markdown
Member

Три свежих процесса, один сид, одна машина:

421.70    496.20    385.10

Два вызова внутри одного процесса — 185.3 и 442.9. test_gail_imitates_expert утверждает порог на этом, поэтому падает примерно раз в десять прогонов и держит каждый PR в репозитории.

Ни один источник — не веса

Оба — данные, и оба невидимы для set_seed ровно по той причине, которую называет его собственный докстринг: он не достаёт до генератора, который объект построил себе сам.

  1. Траектории политики в _collect_policy_transitions стартуют из self.env, а среда владеет np.random.default_rng() без сида. Каждая итерация learn брала начальные состояния из энтропии ОС.

  2. Датасет политики, против которого учится дискриминатор, пересоздавался каждую итерацию как TransitionDataset(...) без сида — то есть индексы минибатчей тоже шли из энтропии ОС, на каждой эпохе дискриминатора каждой итерации.

Сид среды ставится один раз в __init__: reset() сохраняет переданный генератор, так что последующие несеянные сбросы тянут из засеянного потока. Датасет получает сид из собственного генератора агента — он по-прежнему разный на каждой итерации и по-прежнему воспроизводится.

Обе необходимы, ни одна не достаточна

Два прогона в одном процессе, один сид:

конфигурация результат
обе правки 128.8 и 128.8
без сида среды 124.9 и 154.8
без сида датасета 120.0 и 75.6

На параметрах настоящего теста три свежих процесса дают 380.0, 380.0, 380.0 при пороге 200.

Про сам тест

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

set_seed зовётся на каждый прогон — это то, что conftest делает на каждый тест, и это текущий контракт: сети агента инициализируются из глобального состояния torch, а не из собственного сида — здесь и во всех остальных алгоритмах пакета.

дискриминатор без предварительного сида:  -19.640535 / 14.905127
после set_seed(0):                         -6.462276 / -6.462276

То есть seed= сейчас значит «воспроизводимо при том же глобальном состоянии», а не «воспроизводимо». Сделать второе — это изменение во всех агентах, отдельное решение, и протаскивать его сюда неправильно. Но это оставшаяся половина #15.

Что это меняет для #15

Я до этого предлагал статистический обход — медиану пяти сидов. Он больше не нужен для GAIL: у теста просто не работал сид, и починка сида лучше, чем обкладывание статистикой невоспроизводимого прогона. Стоит проверить остальные 24 одноразовых assert'а на то же самое, прежде чем менять протокол.


И то же самое нашлось в GRPO

GRPO(seed=0) дважды: 91.2  94.7

_rollout_episode сбрасывает среду раз в эпизод и никогда с сидом, learn её тоже не сеет. Та же причина, тот же set_seed, который до этого не достаёт.

Засеяно один раз в начале learn — там, где это уже делают off_policy, tabular и sac_discrete. Два прогона теперь дают 145.5 и 145.5. Тест падает с откаченной строкой.

Остальные я проверил, а не предположил

Десять алгоритмов сбрасывают среду внутри цикла обучения. Восемь из них — dqn, dreamer, mbpo, off_policy, reinforce, rssm, sac_discrete, tabular — сеют первый сброс, и несеянные сбросы внутри цикла тянут из засеянного потока. On-policy база и recurrent_ppo так же сеют свою векторную среду. decision_transformer и her сеют поэпизодно намеренно.

GRPO был единственным оставшимся. То есть это два случая в пакете, а не схема, которая где-то ещё прячется.

Three fresh processes, same seed, same machine:

    421.70    496.20    385.10

and two calls inside one process gave 185.3 and 442.9. `test_gail_imitates_expert`
asserts a threshold on that, which is why it fails about one run in ten and has
been blocking every pull request here.

Neither source is the weights. Both are data, and both are invisible to
`set_seed` for the reason its own docstring gives -- it cannot reach a generator
an object built for itself:

  * the policy rollouts in `_collect_policy_transitions` start from `self.env`,
    and an env owns an `np.random.default_rng()` with no seed, so every
    iteration of `learn` drew its starting states from OS entropy;

  * the policy dataset the discriminator trains against was rebuilt each
    iteration as `TransitionDataset(...)` with no seed, so its minibatch indices
    came from OS entropy too, on every discriminator epoch of every iteration.

Seeding the env once in `__init__` is enough for the first: `reset()` keeps the
generator it was handed, so the unseeded resets that follow draw from a seeded
stream. The dataset takes a seed drawn from this agent's own generator, so it
still differs per iteration and still reproduces.

Both are necessary and neither is sufficient. Measured, two runs in one process
at the same seed: both fixes 128.8 and 128.8; env seeding reverted 124.9 and
154.8; dataset seeding reverted 120.0 and 75.6. At the real test's parameters
three fresh processes now return 380.0, 380.0, 380.0 against a threshold of 200.

The new test is sized to catch both -- the first version of it was short enough
to pass with the env fix reverted, which would have been a regression test that
did not regress. It calls `set_seed` per run, matching what conftest does per
test, because that is the contract as it stands: an agent's networks are
initialised from global torch state rather than from its own seed, here and in
every other algorithm in this package. Two constructions of GAILDiscriminator in
one process give different checksums; after `set_seed(0)` they match. So `seed=`
means "reproducible given the same global state" and not yet "reproducible".
Making it mean the second is a change to every agent and belongs in its own
decision, not smuggled in here -- but it is the remaining half of #15.
    GRPO(seed=0) дважды: 91.2  94.7

`_rollout_episode` resets the env once per episode and never with a seed, and
`learn` never seeds it either, so every episode started from OS entropy. Same
cause as GAIL's rollouts, same reason `set_seed` cannot help: an env owns an
`np.random.default_rng()` built without a seed, which its docstring says.

Seeded once at the top of `learn`, which is where `off_policy`, `tabular` and
`sac_discrete` already do it. A reset keeps the generator it was handed, so the
per-episode resets that follow draw from a seeded stream. Two runs now return
145.5 and 145.5.

I audited the rest rather than assuming. Ten algorithms reset inside their
training loop; eight of them -- dqn, dreamer, mbpo, off_policy, reinforce, rssm,
sac_discrete, tabular -- seed the first reset, and the on-policy base and
recurrent_ppo seed their vector env the same way. GRPO was the only one left,
and `decision_transformer` and `her` seed per episode deliberately. So this is
two cases in the package, not a pattern still hiding elsewhere.

The test fails with this line reverted, which is the only reason to believe it.
@DenisDrobyshev DenisDrobyshev changed the title GAIL(seed=0) не значил ничего GAIL и GRPO: seed= не значил ничего Sep 20, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant