Skip to content

concat: stack obsm dataframes, merge varm - #69

Merged
Claptar merged 3 commits into
mainfrom
fix/concat-obsm-varm-frames
Oct 22, 2026
Merged

Claptar merged 3 commits into
mainfrom
fix/concat-obsm-varm-frames

Conversation

@Claptar

@Claptar Claptar commented Oct 22, 2026

Copy link
Copy Markdown
Contributor

SCope and pycisTopic imports put dataframes in obsm (RegulonsAUC) and varm (Regulons, topic_region). concat skipped the obsm ones ("not a dense array in every input") and never wrote varm at all, though its docstring said the other axis follows --merge.

  • obsm dataframes are stacked as obs is, on the target obs names. --join inner keeps the columns every input has; --join outer takes their union, missing where an input lacks one. An array in one input and a dataframe in another is still skipped, with a note.
  • varm follows --merge as var's columns do (same, unique, first, only). Each input's entry is aligned on the target var; an input without some of the target's genes (outer join) counts as missing. Arrays and dataframes both. Without --merge, varm is dropped with a note.

Tests compare against anndata.concat for both joins and every merge strategy; two perf guards count reads (varm merge linear in n_var, obsm frames linear in n_obs). Also checked on two h5ads converted from the public pySCENIC pbmc3k loom: obsm and varm equal anndata.concat(..., merge="same").

🤖 Generated with Claude Code

SCope and pycisTopic imports leave dataframes in obsm (RegulonsAUC) and
varm (Regulons, topic_region). concat skipped the obsm ones ("not a dense
array in every input") and never wrote varm at all, though its docstring
said the alternative axis follows --merge, as anndata's concat does.

- obsm: a dataframe in every input is stacked as obs is, its columns
  following --join, through the obs column writers.
- varm: merged as var's columns are, on the output's genes. same/unique
  compare a snapshot read once per input (exact bytes, so NaN equals NaN);
  first/only go by presence. An input lacking some genes (an outer join)
  counts as missing. Without --merge it is dropped, with a note.

Checked against anndata.concat for each join and strategy, and on two
h5ads converted from the Zenodo pySCENIC loom.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 22, 2026 12:50
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-22T13:40:47.101879Z 65060c6 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.

@github-actions

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (py3.12)

1 681 tests   1 681 ✅  4m 17s ⏱️
    1 suites      0 💤
    1 files        0 ❌

Results for commit 800c409.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (py3.13)

1 681 tests   1 681 ✅  2m 20s ⏱️
    1 suites      0 💤
    1 files        0 ❌

Results for commit 800c409.

♻️ This comment has been updated with latest results.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1a671d14f1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/adata/core/concat.py Outdated
"""A comparable value of a varm entry, on the target var: its bytes for
an array; each column's, for a dataframe. Exact, so NaN equals NaN."""
if is_dataset(obj):
values = np.asarray(obj[...])[where]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Stream varm entries within the chunk budget

When --merge same or unique encounters a large varm array or dataframe column, this reads the entire source into memory, copies the aligned selection, and retains a full byte snapshot for every input; the later write also materializes the whole selected entry. Consequently, peak memory scales with the complete varm data and input count rather than --chunk, so realistic pycisTopic region-by-topic arrays can exhaust memory during an otherwise streamed concat. Compare and copy these entries in chunks instead.

AGENTS.md reference: AGENTS.md:L6-L11

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 65060c6. Under same/unique each input's entry is now reduced to a blake2b digest read --chunk genes at a time (_varm_snapshot), and the kept entry is copied the same way (_write_varm_array). Genes in another order are read sorted and put back in the target's order per block. A dataframe entry is digested one column at a time, each column var-long, the same budget _write_var uses for var's columns.

Comment thread src/adata/core/concat.py
Comment on lines +752 to +753
if _concat_obs_column(frame, column, [s[column] if column in s else None for s in sources],
lengths, console)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Stream obsm dataframe columns

For an obsm dataframe with many observations, this delegates each column to _concat_obs_column, whose numeric, masked, categorical, and string paths read complete source columns and allocate a complete output column. Thus adding RegulonsAUC support makes concat memory grow with total observations regardless of chunk_rows; these columns need a chunked writer rather than reusing the in-memory obs-column implementation.

AGENTS.md reference: AGENTS.md:L6-L11

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Leaving this as is, deliberately: an obsm dataframe is written one column at a time, and each column is n_obs long — the same budget every obs column already takes in _write_obs through the same _concat_obs_column, and smaller than the obs index (target_obs) concat holds whole. So it doesn't move peak memory past what obs alone sets. Streaming obs-length columns in blocks would be a change to obs as much as to obsm; I'd rather do that for both in its own PR.

Comment thread src/adata/core/concat.py Outdated
an array; each column's, for a dataframe. Exact, so NaN equals NaN."""
if is_dataset(obj):
values = np.asarray(obj[...])[where]
return ("array", str(values.dtype), values.shape, values.tobytes())

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Compare object-string varm arrays by value

When a dense varm array contains HDF5 variable-length strings, np.asarray produces an object-dtype array and tobytes() serializes Python object pointers rather than string contents. Independently read arrays with identical strings can therefore compare unequal—or accidentally compare equal after address reuse—causing same/unique to keep or drop the key incorrectly; use semantic value comparison as anndata's merge implementation does.

AGENTS.md reference: AGENTS.md:L145-L146

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 65060c6: text is now fed to the digest as its UTF-8 text, length-prefixed (_digest), never as object bytes. Test: test_varm_text_arrays_are_compared_by_their_text.

Comment thread src/adata/core/concat.py Outdated
source = next(i for i, v in enumerate(aligned) if v is not _MISSING)
obj, take = roots[source]["varm"][name], positions[source]
if is_dataset(obj):
write_dense(varm, name, np.asarray(obj[...])[take])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve the chosen varm source layout

When a kept dense varm entry comes from a chunked or compressed HDF5/Zarr dataset, write_dense recreates it without the source's dataset creation settings, silently discarding its chunking and compression and potentially inflating the output substantially. Derive and clamp the creation kwargs from the selected source before writing.

AGENTS.md reference: AGENTS.md:L134-L135

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 65060c6: the output dataset now takes dataset_create_kwargs from the chosen source, fitted with fit_chunks, as subset's copies do. Test: test_a_kept_varm_array_keeps_its_compression.

Comparing varm under same/unique read every input's whole entry and
kept its bytes; now each input is reduced to a digest a block of
rows at a time, and the kept entry is copied the same way, with the
source's chunking and compression. Text arrays are compared by their
text, not by the object pointers their bytes held.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Claptar

Claptar commented Oct 22, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 65060c6901

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/adata/core/concat.py
Comment on lines +812 to +813
kind = "text" if is_string_dtype(obj.dtype) else str(obj.dtype)
return ("array", kind, tuple(obj.shape[1:]), hasher.digest())

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Compare numeric varm arrays by semantic value

When corresponding varm arrays store equal values in different numeric dtypes—for example, int32 versus int64—including str(obj.dtype) makes their snapshots distinct even though anndata's array equality considers their values equal. Consequently, --merge same and --merge unique silently drop a key that the documented oracle retains; normalize numeric values for comparison rather than including the storage dtype in their identity.

AGENTS.md reference: AGENTS.md:L145-L146

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Checked against the oracle, and anndata does the opposite: with anndata 0.13.3, ad.concat(..., merge="same") drops a varm array that is int64 in one input and int32, float32 or float64 in the other, and keeps it only when the dtypes match too. So keeping the dtype in the comparison is what matches it. Pinned in 800c409: test_varm_in_another_dtype_merges_as_anndata_merges_it compares our keys with anndata.concat's for each dtype.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Claptar
Claptar merged commit 7150d3c into main Oct 22, 2026
6 checks passed
@Claptar
Claptar deleted the fix/concat-obsm-varm-frames branch October 22, 2026 15:25
@Claptar
Claptar restored the fix/concat-obsm-varm-frames branch October 22, 2026 15:56
@Claptar
Claptar deleted the fix/concat-obsm-varm-frames branch October 22, 2026 15:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants