ML Foundations

Why a Python generator can leave the second training epoch empty

A generator stores iterator state. Create a fresh iterator for each pass, or deliberately cache a small finite source before reusing it.

Evidence and testing

Review record: Tensor Almanac automated evidence gate (no independent human review). Example versions, recorded outputs and limitations appear below.

A generator object represents one advancing pass

A training loop can complete its first pass over a Python generator and receive no rows on the next pass. The generator object retains its iteration state. Python requires an iterator to return itself when iter is called, and to remain exhausted after it reaches StopIteration. Calling iter on that same object is therefore not a rewind operation. [1]

The fixture creates one generator yielding row IDs 10, 20 and 30. Collecting it twice produces lengths three and zero. A separate counting pass consumes another generator before the supposed training pass even begins. This exposes why a successful preflight count does not establish that the same stream still contains data.

References: [1]

Create iterator state inside the per-pass boundary

Call the generator function again when starting each pass. The example calls rows twice and verifies that the results are distinct generator objects yielding the complete sequence. Python documents generator functions as a way to create iterators and explains that each next call resumes saved execution state. Fresh construction gives each pass its own state. [1, 2]

Run python examples/generator_epoch_restart.py. Assertions cover first and second passes, exhaustion after counting, independent generator instances and a partially consumed generator. After one item is consumed, iter returns the remaining two items rather than restarting from the first. An empty source is also checked.

The tuples used to inspect outputs are small test records. A real streaming consumer can process each yielded row directly; the factory approach does not require collecting a complete pass in memory. What matters is constructing a fresh source at the intended boundary rather than handing back the same exhausted object.

Executed with Python 3.12.14 using no third-party dependencies. Exact stdout and source-byte SHA-256 are preserved in private evidence.

"""Show a consumed generator, a fresh-per-pass factory, and a small cached tuple."""


def rows():
    yield from (10, 20, 30)


shared = rows()
assert iter(shared) is shared
first = tuple(shared)
second = tuple(shared)
assert first == (10, 20, 30) and second == ()
assert next(iter(shared), None) is None
print('shared generator epoch lengths:', [len(first), len(second)])

counted = rows()
count = sum(1 for _ in counted)
after_count = tuple(counted)
assert count == 3 and after_count == ()
print('pre-count and remaining rows:', count, len(after_count))

# The factory creates new iterator state for every pass.
a, b = rows(), rows()
assert a is not b
fresh_passes = [tuple(a), tuple(b)]
assert fresh_passes == [(10, 20, 30), (10, 20, 30)]
print('fresh factory epoch lengths:', [len(items) for items in fresh_passes])

# Materialize once only when the finite source comfortably fits in memory.
cached = tuple(rows())
assert iter(cached) is not iter(cached)
cached_passes = [tuple(iter(cached)), tuple(iter(cached))]
assert cached_passes == fresh_passes
print('cached tuple epoch lengths:', [len(items) for items in cached_passes])

partial = rows()
assert next(partial) == 10
assert tuple(iter(partial)) == (20, 30)
assert tuple(iter(partial)) == ()
print('iter on a partial generator resumes instead of rewinding:', True)
assert tuple(item for item in ()) == ()
print('empty source remains empty:', True)

Recorded output

shared generator epoch lengths: [3, 0]
pre-count and remaining rows: 3 0
fresh factory epoch lengths: [3, 3]
cached tuple epoch lengths: [3, 3]
iter on a partial generator resumes instead of rewinding: True
empty source remains empty: True

References: [1], [2]

Cache deliberately when the source is finite and small

The second repair materializes the source once as a tuple, then creates a new iterator over that stored sequence for each pass. Both passes contain the same three row IDs in the executed fixture. This trades storage for replay and is appropriate only when the finite source comfortably fits in memory.

A fresh generator object is not enough if its body closes over an already-consumed external stream. A real factory must recreate or reopen the underlying source as well. This example uses a fixed tuple and exercises no files, remote streams, loader workers or model training. Source repeatability and lifecycle must be checked separately when adapting the pattern.

References: [1], [2]

Sources

  1. Python iterator protocol: identity and permanent exhaustion
  2. Python tutorial: generator state and iteration