Skip to content

Guard CoW read-only .to_numpy()/.values mutations under pandas 3 - #3416

Open
AxelNoun wants to merge 9 commits into
DeepLabCut:mainfrom
AxelNoun:fix/pandas3-cow-readonly-tonumpy
Open

Guard CoW read-only .to_numpy()/.values mutations under pandas 3#3416
AxelNoun wants to merge 9 commits into
DeepLabCut:mainfrom
AxelNoun:fix/pandas3-cow-readonly-tonumpy

Conversation

@AxelNoun

Copy link
Copy Markdown

Guard in-place mutations of .to_numpy() / .values results (Copy-on-Write, pandas 3.0)

Follow-up to #3360. Related to the tracking issue #3362.

Summary

Under pandas 3.0's Copy-on-Write, .to_numpy() and .values return a
read-only array for single-dtype selections; mutating that array in place
raises ValueError: assignment destination is read-only. This PR guards the five
sites where such an array is mutated, using to_numpy(copy=True).

The pandas<3 upper bound is intentionally left unchanged — this is a
forward-compatibility fix, not the pin removal discussed in #3362. It complements
the preparatory work already merged in #3360 and is safe on the currently pinned
pandas 2.x.

The pattern

arr = df.to_numpy()      # or df.values  → read-only view under CoW (single dtype)
arr[mask] = value        # ValueError: assignment destination is read-only

copy=True returns a writable array and is behavior-preserving on pandas 2.x, so
the change is correct under both major versions.

Changes

to_numpy(copy=True) at the sites that mutate the extracted array in place:

  • pose_estimation_pytorch/data/ctd.py
  • pose_estimation_3d/triangulation.py
  • pose_estimation_3d/plotting3D.py
  • refine_training_dataset/tracklets.py
  • post_processing/filtering.py — spline-filter branch (same interpolation
    pattern as tracklets.py; only the tracklets copy is covered by the current
    tests, so this one was located by a static scan rather than a failing test)

Considered and left unchanged: pose_estimation_tensorflow/core/evaluate_multianimal.py
extracts a mixed-dtype selection (["sample", "y", "x", "bodyparts"]), which
yields a writable object array and is therefore not affected.

Testing

  • Locally against pandas 3.0.3 + PyTables 3.11.1: the CTD-HDF tests pass after
    the fix, and the spline-filter path is smoke-tested (writable arrays, correct
    gap-filling).
  • CI continues to run on pandas 2.x (unchanged pin), where these changes are
    behavior-preserving.

Notes for #3362

  • This is a class of pandas-3 break not covered by Prepare migration to pandas 3.0 #3360 — it only surfaces at
    runtime under pandas 3.0, so it isn't caught by the 2.3 future-mode tooling.
  • While validating against released pandas 3.0.3, the HDF concern described in
    Migration to Pandas 3.0 #3362 ("PyTables cannot serialize a MultiIndex whose levels use extension
    dtypes → change all dataframes back to object before saving"
    ) does not
    reproduce: pandas 3.0 explicitly whitelists StringDtype in that guard
    (io/pytables.py, write_multi_index:
    isinstance(lev.dtype, ExtensionDtype) and not isinstance(lev.dtype, StringDtype)),
    and DeepLabCut's string index/column levels serialize fine (verified for the
    df_with_missing / tracks / predictions / ass keys). So no to_hdf
    object-conversion wrapper appears necessary on the released 3.0. (The pandas 2.3
    future.infer_string mode does raise there, since the exemption only lands in
    3.0 — so that mode shouldn't be used to validate HDF writes.)

AxelNoun and others added 2 commits July 19, 2026 19:04
Under pandas 3.0 Copy-on-Write, .to_numpy()/.values return read-only
arrays for single-dtype selections; in-place mutation raises
ValueError. Use copy=True at the five affected sites. Pin unchanged.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves forward-compatibility with pandas 3.0 Copy-on-Write by ensuring NumPy arrays extracted from pandas objects remain writable at the specific sites where the code performs in-place mutations.

Changes:

  • Use to_numpy(copy=True) where extracted arrays are mutated in place (instead of relying on .to_numpy()/.values views that can be read-only under pandas 3 CoW).
  • Add inline comments documenting the pandas 3 CoW read-only-view behavior at each updated site.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated no comments.

Show a summary per file
File Description
deeplabcut/refine_training_dataset/tracklets.py Make gap-filling array extraction writable under pandas 3 CoW.
deeplabcut/post_processing/filtering.py Ensure spline-filter branch extracts writable xy/prob arrays before in-place filling.
deeplabcut/pose_estimation_pytorch/data/ctd.py Ensure per-row pose arrays are writable before masking/cleanup.
deeplabcut/pose_estimation_3d/triangulation.py Ensure triangulation masking operates on writable arrays extracted from DataFrames.
deeplabcut/pose_estimation_3d/plotting3D.py Ensure in-place NaN masking for visualization operates on writable arrays.
Comments suppressed due to low confidence (1)

deeplabcut/refine_training_dataset/tracklets.py:230

  • In load_tracklets_from_hdf, data = df.to_numpy(copy=True) fixes the first read-only array issue, but later self.data = df.values.reshape(...) can still return a read-only NumPy view under pandas 3 CoW for homogeneous float DataFrames. Since self.xy/self.prob are mutated later (e.g. via swap_tracklets), this can still raise ValueError: assignment destination is read-only after loading from HDF.
        data = df.to_numpy(copy=True)
        mask = ~df.columns.get_level_values(level="coords").str.contains("likelihood")
        xy = data[:, mask]
        prob = data[:, ~mask]
        missing = np.isnan(xy)

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@AxelNoun

Copy link
Copy Markdown
Author

Thanks for the review ? agreed on the remaining load_tracklets_from_hdf site.

Pushed a follow-up that guards the three remaining CoW-sensitive mutations we could reproduce under pandas 3:

  1. refine_training_dataset/tracklets.py (the site you flagged): self.data = df.values.reshape(...).swapaxes(...) ? df.to_numpy(copy=True).... self.xy / self.prob are views into self.data and are mutated in place (e.g. swap_tracklets / refine GUI). Verified end-to-end against a real HDF: ValueError: assignment destination is read-only before the fix, OK after.

  2. utils/make_labeled_video.py: xyp = df.values.reshape(...) ? df.to_numpy(copy=True).... coords is a view into xyp and is masked in place in two places (first-frame branch and per-frame loop); the single copy=True covers both.

  3. pose_estimation_tensorflow/.../pose_multianimal_imgaug.py: data.to_numpy() ? to_numpy(copy=True). A single-row float Series returns a read-only view under pandas 3 CoW; kpts is then masked in place when mask_kpts_below_thresh=True. Reproducible; included because it is the same failure mode as the rest of this PR.

Also ran ruff format . so the tree passes ruff format --check (the earlier CoW edits in plotting3D.py / triangulation.py / ctd.py needed reformatting for CI).

Considered, left unchanged

  • evaluate_multianimal.py:331: under pandas 3.0.2 the .loc[:, [list]] reordering path returns a writable array (consolidation/copy). Not reproducible ? no change. Adding copy=True would be defensive only.
  • Other candidates from the AST audit (calc_bboxes_from_keypoints, np.nan_to_num(..., copy=False), columnwise_spline_interp and callers) either create fresh writable arrays or never mutate their input.

Out of scope (optional follow-up)

In load_tracklets_from_hdf, just above the fixed line, there is a redundant round-trip: a NumPy data array is re-wrapped as pd.DataFrame(...) then immediately re-extracted via to_numpy(copy=True). It could be self.data = data.reshape(...).swapaxes(...) directly (data is already writable). Left out intentionally to keep this PR a minimal CoW guard; happy to open a separate cleanup if useful.

AxelNoun and others added 3 commits July 25, 2026 20:08
Follow-up to the pandas 3.0 CoW sweep (DeepLabCut#3362). Three more sites extract a
NumPy array from a pandas object and mutate it in place; under pandas 3 CoW
these can be read-only views, raising "assignment destination is read-only".

- refine_training_dataset/tracklets.py: self.data backs self.xy/self.prob,
  which swap_tracklets and the refine GUI mutate in place (HDF load path;
  flagged in review).
- utils/make_labeled_video.py: coords is a view into xyp, masked in place in
  both the first-frame and per-frame branches.
- pose_estimation_tensorflow/.../pose_multianimal_imgaug.py: a single-row
  float Series is read-only; kpts is masked in place when
  mask_kpts_below_thresh is set.

Co-authored-by: Cursor <cursoragent@cursor.com>

@deruyter92 deruyter92 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@AxelNoun
Apologies, I forgot to respond myself before requesting a CoPilot review. Thanks for addressing CoPilots suggestions as well.

I've reviewed your changes, and they are definitely correct and very helpful!

Thanks for catching these exceptions for the future-tooling in utils/pandas_future_mode.py. When extracting numpy arrays from the DataFrame, the warning is circumvented. I'm changing the mode from "warn" to True, to fully simulate the 3.0 behavior, and adding some tests as well.

@AxelNoun

Copy link
Copy Markdown
Author

Thanks Jaap, glad it's useful! And good call flipping the mode to True in warn mode extracting a numpy array sidesteps the CoW tracking entirely (no warning, no error), so none of these would ever have surfaced in CI. Full 3.0 enforcement is the right check.
For completeness on the one .loc[:, cols].to_numpy() + in-place write I deliberately left untouched (evaluate_multianimal.py:331): it's safe because that frame is multi-block, so the column selection consolidates into a fresh writable array rather than a view. I re-checked on pandas 2.3.3 with CoW enabled and it stays writable unlike the homogeneous single-block case your column_subset test covers, where even a subset is a read-only view. It comes down to block layout, not the .loc[] syntax. Happy to add a copy=True there too as cheap insurance if you'd rather not depend on the frame staying multi-block.

AxelNoun and others added 3 commits July 27, 2026 18:28
Guards against the in-place write on the next line producing a
read-only view when the intermediate frame becomes single-block.
@deruyter92

Copy link
Copy Markdown
Collaborator

Thanks @AxelNoun, good suggestion. Even though it is currently safe, I would lean towards adding copy=True as an explicit insurance. Probably worth the minimal performance cost.

I've already applied it now in 7c99777.

@deruyter92 deruyter92 added the dependencies Pull requests that update a dependency file label Jul 28, 2026
@AxelNoun

Copy link
Copy Markdown
Author

@deruyter92 OK, perfect! Thank you, if you need me to change anything, I am available, thank you for your time!

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

Labels

dependencies Pull requests that update a dependency file

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants