fix: bugs related to rng, mask and deepcopy - #285
selmanozleyen wants to merge 4 commits into
Conversation
DistributedSampler kept the sampler it wrapped by reference and re-sharded its mask on every n_batches/validate/_sample call, so wrapping left the caller's own sampler rank-sharded and re-seeded. Shard a copy. split_given_size is np.split, which returns views, and every caller reshuffles one row-id buffer between windows, so an already-yielded load request's splits changed underneath the caller. Copy the pieces out in the helper, where all the samplers route through. ClassSampler also hoisted the split list out of its window loop, so that moves back in. A mask assigned mid-pass had no effect on that pass, whose slices are all drawn when it starts. Raise, but only when the range actually moves, since DistributedSampler re-assigns the same shard on every call. class_weights as a pandas Series converted positionally and dropped the index, silently moving weights onto other classes. Reject it. `rng or np.random.default_rng()` swapped a falsy generator for a fresh unseeded one, so rng=0 lost reproducibility without a word while rng=42 failed loudly at the first draw. Both constructors now route through resolve_rng, which defaults only None and rejects a seed with a message saying what to pass instead. Also correct the ClassSampler summary, which said a class is drawn per batch when it is drawn per lcm(chunk_size, batch_size) rows. The Implementation section already had the rule; the summary contradicted it.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #285 +/- ##
==========================================
+ Coverage 91.30% 91.62% +0.32%
==========================================
Files 18 18
Lines 1495 1517 +22
==========================================
+ Hits 1365 1390 +25
+ Misses 130 127 -3
🚀 New features to boost your workflow:
|
| self._sampler = sampler | ||
| if sampler.rng is not None: | ||
| sampler.rng = _spawn_worker_rng(sampler.rng, self._rank) | ||
| # a copy: n_batches/validate/_sample re-shard this sampler's mask on every call |
There was a problem hiding this comment.
What does "reshard" mean?
There was a problem hiding this comment.
it was lingo for paritioning the data from the existing code. on distributed sampler
There was a problem hiding this comment.
Ok I see now - these update the mask. My other comment touched on this point - I don't think this is necessarily needed. At least not n_batches
| if sampler.rng is not None: | ||
| sampler.rng = _spawn_worker_rng(sampler.rng, self._rank) | ||
| # a copy: n_batches/validate/_sample re-shard this sampler's mask on every call | ||
| self._sampler = copy.deepcopy(sampler) |
There was a problem hiding this comment.
Ok, right, so this is basically what #250 is supposed to fix, right? Our own copy method should handle this sort of thing? Or?
There was a problem hiding this comment.
No because there is no copy call in the previous code that hooks into anything. Making our classes deepcopyable explicitly doesn't also guarantee we deepcopied everything before. Or that every consumer of that class deepcopied it before using it. We can't have that in python. This is not only special to rng's, it's also for any non primitive we pass. We could also observe such a bug in classes for example. A caller who passed it might modify it later.
Perhaps we can make a repo wide rule for samplers, that they should have full ownership of the attributes they have by deepcopy. But I think we should make it limited to our own samplers. Loader still works with any Sampler. DistributedSampler wouldn't work for example with a sampler that has a file open (extreme example I know but still ilustrates the ontology difference).
On #250, I think this is why we should just raise on copy.copy()/__copy__ and allow only copy.deepcopy()/__deepcopy__.
There was a problem hiding this comment.
Making our classes deepcopyable explicitly doesn't also guarantee we deepcopied everything before.
Yeah, I just meant we should do this, not that it was being done. We should make our classes have explicit deep copy behavior instead of relying on whatever python thinks we want.
On #250, I think this is why we should just raise on copy.copy()/copy and allow only copy.deepcopy()/deepcopy.
Right, this is the sort of thing I am getting at. The rest of your comment I agree with.
| def split_given_size(a: np.ndarray, size: int) -> list[np.ndarray]: | ||
| """Wrapper around `np.split` to split up an array into `size` chunks""" | ||
| return np.split(a, np.arange(size, len(a), size)) | ||
| """Wrapper around `np.split` to split up an array into `size` chunks. | ||
|
|
||
| `np.split` returns views, so the pieces are copied: callers reshuffle one row-id buffer | ||
| between windows, which would rewrite the splits they already yielded. | ||
| """ | ||
| return [piece.copy() for piece in np.split(a, np.arange(size, len(a), size))] |
There was a problem hiding this comment.
Are we sure every single place needs a copy? Maybe we should add a copy argument and keep things localized to the places that actually need a copy? WDYT?
There was a problem hiding this comment.
Yeah I think a copy argument makes sense. The only legal consumer of Sampler is Loader, we don't even need to copy atm.
The only reasonable case would be when someone wants to get the full schedule ahead. Having an explicit arg would help document this. But I am not sure about copy. I'd perhaps call it reuse_splits:bool = True or something to make it clearer.
There was a problem hiding this comment.
I think copy is clearer and widely known.
There was a problem hiding this comment.
But it could be confused with other things for this reason no? Like could it also mean it copies the inner sampler? and not deep copy? Also there isn't another case where something is copied here
There was a problem hiding this comment.
Also it would be a user facing arg right?
There was a problem hiding this comment.
I think you misunderstood what I was saying. I was saying split_given_size gets a copy argument that is used when corrupting the outputs is a real concern. But when we internally control the output of split_given_size, we don't need copy. I wasn't saying the Sampler' functions need a copy` argument that copies splits - the splits the samplers yield should not be corruptible views, your bug description was correct. Sorry for the misunderstanding.
| # `len(loader)` mid-epoch goes through DistributedSampler.n_batches, which re-assigns the | ||
| # same shard; re-assigning the range a pass is already reading is a no-op, not a move |
There was a problem hiding this comment.
Looking at
annbatch/src/annbatch/samplers/_distributed_sampler.py
Lines 130 to 141 in b8385d7
n_batches should just not set the mask and instead answer the query of how many batches are yielded for a given number of observations. No?
There was a problem hiding this comment.
I agree but can it be a separate PR?
There was a problem hiding this comment.
This, and the presence of the same behavior in validate and _sample seem pretty important - I can't remember what the need of that side-effect is. I'm wondering if there is a way we can mitigate the bugs here but simply not having that side effect.
1b54d72 to
cc5e553
Compare
| - `class_weights` given as a {class}`pandas.Series` now raises {class}`TypeError`. It used to be read positionally, discarding the index and attaching the weights to the wrong classes; pass `class_weights.reindex(classes.categories).to_numpy()` instead. | ||
| - `rng` must now be a {class}`numpy.random.Generator` or `None`; anything else raises {class}`TypeError`. A falsy seed such as `rng=0` used to be silently swapped for a fresh unseeded generator, losing reproducibility; pass `np.random.default_rng(0)` instead. |
There was a problem hiding this comment.
We just released a minor version, so let's not have to do breaking changes.. The first one is a bug since it corrupted data and the second can be smoothed out with a warning.
| def split_given_size(a: np.ndarray, size: int) -> list[np.ndarray]: | ||
| """Wrapper around `np.split` to split up an array into `size` chunks""" | ||
| return np.split(a, np.arange(size, len(a), size)) | ||
| """Wrapper around `np.split` to split up an array into `size` chunks. | ||
|
|
||
| `np.split` returns views, so the pieces are copied: callers reshuffle one row-id buffer | ||
| between windows, which would rewrite the splits they already yielded. | ||
| """ | ||
| return [piece.copy() for piece in np.split(a, np.arange(size, len(a), size))] |
There was a problem hiding this comment.
I think you misunderstood what I was saying. I was saying split_given_size gets a copy argument that is used when corrupting the outputs is a real concern. But when we internally control the output of split_given_size, we don't need copy. I wasn't saying the Sampler' functions need a copy` argument that copies splits - the splits the samplers yield should not be corruptible views, your bug description was correct. Sorry for the misunderstanding.
| # `len(loader)` mid-epoch goes through DistributedSampler.n_batches, which re-assigns the | ||
| # same shard; re-assigning the range a pass is already reading is a no-op, not a move |
There was a problem hiding this comment.
This, and the presence of the same behavior in validate and _sample seem pretty important - I can't remember what the need of that side-effect is. I'm wondering if there is a way we can mitigate the bugs here but simply not having that side effect.
fixes #278 and #271