fix: support adding/removing variables after adding frozen constraint#806
fix: support adding/removing variables after adding frozen constraint#806koen-vg wants to merge 3 commits into
Conversation
Merging this PR will not alter performance
Comparing Footnotes
|
Frozen (CSR-backed) constraints cache their matrix at freeze time, sized against the active-variable set as it was then. Adding variables afterwards (e.g. an auxiliary variable introduced by a constraint added after this one was frozen, common under Model(freeze_constraints=True) when building incrementally) grows the active-variable count, but CSRConstraint.to_matrix and to_matrix_with_rhs returned the cached CSR verbatim, ignoring the VariableLabelIndex passed in. The earlier-frozen blocks stay narrower than later ones, so stacking them fails with "ValueError: incompatible dimensions for axis 1". This breaks the invariant documented in PyPSA#630 -- to_matrix on both the mutable and frozen constraint "always returns a csr matrix of shape (n_active_cons, n_active_vars)" -- which the VariableLabelIndex exists precisely to uphold. Widen the cached CSR to the current n_active_vars in a shared _csr_for_index helper used by both to_matrix (matrix-accessor path) and to_matrix_with_rhs (direct-solve path). Variable positions are assigned in encounter order and are stable under additions, so widening only appends empty trailing columns. Adds differential tests (freeze vs mutable must agree) covering the baseline, variables added after freeze, and constraints linking later-added variables.
Frozen (CSR-backed) constraints store absolute dense variable positions from freeze time. Removing a variable renumbers the positions of every later variable, but Model.remove_variables left surviving frozen constraints with those stale positions -- both the wrong width and the wrong columns -- producing a corrupt constraint matrix (e.g. shape (2, 5) instead of (2, 2)) or an IndexError at solve time. The mutable Constraint path handles removal correctly, so this was a frozen-vs-mutable parity gap. remove_variables now captures the pre-removal variable ordering and, once the variable (and any constraints referencing it) are gone, remaps each surviving frozen constraint's CSR columns through variable labels via the new CSRConstraint._remap_columns. The old ordering is still available at the mutation site, so no per-constraint label storage is needed; a frozen constraint that referenced the removed variable has already been dropped, so every remaining column maps to a valid new position. Extends the differential tests with removal scenarios: first/middle-variable removal, straddling references, sequential removals, remove-then-add, and the drop-referencing-constraint parity case.
b690853 to
b2b622c
Compare
coroa
left a comment
There was a problem hiding this comment.
Oh, damn. I fucked up. I don't know anymore why i suggested active variables. I had an intermediate representation where i was going for the variables shape being mapped to the full xCounter range and then somehow ended up with changing to the active constraints first and then variables, but this remapping need is bad.
Thanks for seeing that @koen-vg .
I don't think the n.remove_variable is a good place for the remapping to happen. In principle you could also imagine variable mask changes (how do we do those?) to have the same effect! Should we have a short call how to fix this, @FabianHofmann ?
| if frozen_cons: | ||
| assert old_vlabels is not None # captured whenever frozen_cons is non-empty | ||
| new_label_index = self.variables.label_index | ||
| for con in frozen_cons: | ||
| con._remap_columns(old_vlabels, new_label_index) | ||
|
|
There was a problem hiding this comment.
Not the right place for the remapping, this will lead to spaghetti eventually.
|
I suggest that we hold something like a reference to the if self.vlabels is self.model.variables.index.vlabels:
return self._csr
csr = self._csr
if (self.vlabels != self.model.variables.index.vlabels[:self.shape[1]]).any():
# more changes than additions, ie. remap
csr = ...
csr = sp.sparse.csr_matrix(self._csr..., ..., shape=(self.shape[0], self.self.model.variables.index.vlabels[:]))
self._csr = csr
return csr |
|
@coroa thanks for picking up this thread. I will admit that I was just trying to apply a simple fix that made my local modelling work, and wasn't too sure about the wider context and "morally correct" place to apply the fix. Happy to update the PR along the lines you suggest though! I thought a little bit about it, and it seems to make sense. But I'm not super familiar with linopy internals. If you want to work on this yourself, feel free. |
@FabianHofmann assigned the review to himself, so i was kinda hoping with the limited breadcrumbs i left, he'd whip up an amazing solution 😀 |
|
but feel free to give it a go yourself. the only additional comment i have would be that it probably makes sense to test two/three remapping strategies and/or think about whether there is an easy remapping cache strategy. |
|
my intuition would be that something like new_colinds = np.searchsorted(old_vlabels, vlabels)[colinds] would be the most efficient remapping and even more so if you cache this intermediate |
Replace the two site-specific fixes (widening in to_matrix, eager column remap in Model.remove_variables) with a single lazy reconciliation on the CSRConstraint itself. The frozen CSR records the active-variable basis (label_index.vlabels) its column indices refer to; every consumer that interprets those indices goes through _reconciled_csr(), which - returns the stored CSR unchanged while the basis object is unchanged (the common case, O(1)); - widens the shape when the stored basis is a prefix of the current one (variables were only added; positions are stable); - otherwise remaps each stored position through its variable label to the new position (variables were removed, possibly plus additions), raising a clear error if a still-referenced variable is gone. This covers all mutation paths (including ones that bypass Model.remove_variables) and all consumers - matrix assembly, .data reconstruction, repr, to_polars, iterate_slices, has_variable and netcdf export - without hooks in model.py. Also fixes repr of frozen constraints decoding CSR column positions as variable labels, which crashed or printed wrong variables for models with masked variables, and makes Constraints.reset_dual preserve the indicator data and basis of frozen constraints. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GcUKeYa1GvsxyMQsLgf7y1
|
Okay so this was my attempt at the suggestion solution. I think it holds up. One comment, in the new implementation there is this ValueError: Now, this isn't really supposed to happen, but if a user were to call |
I like the new
CSRConstraintand the memory benefits it comes with. The current implementation, however, breaks when you add or remove variables after adding a frozen constraint. This is because existing CSRConstraints record the number of active variables (matrix columns) in ashapeparameter, which becomes stale when a new variable is added. When a variable (column) is removed, the CSR indices change and have to be rebuilt.This PR makes it so CSR representations are updated when variables are added and removed. Especially in the case of adding variables, this is very low-impact, since the actual CSR data doesn't change; only the "shape" does.
I guess there is an argument to be made for a stricter kind of behavior: that no variables may be added or removed after any CSRConstraint has been added to the model. I believe that may be overly strict since the solution in this PR seems to work fine, but happy to discuss the alternative if anyone else has any insights. In any case, though, if the intention is that no variables may be added or removed when CSRConstaints are present, then this should be enforced properly at variable addition/removal. (Currently, you just get an obscure matrix alignment error when passing the model to a solver.)
Code and tests were written with the help of Claude Code.
Changes proposed in this Pull Request
to_matrixin case any variables were added and matrix shape is stale.Checklist
AGENTS.md).doc.doc/release_notes.rstof the upcoming release is included.