Skip to content

Feature: tl.peptide_proximity() - #38

Merged
idf-io merged 1 commit into
mainfrom
feat/tl-proximity-analysis
Sep 3, 2026
Merged

Feature: tl.peptide_proximity()#38
idf-io merged 1 commit into
mainfrom
feat/tl-proximity-analysis

Conversation

@idf-io

@idf-io idf-io commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Feature: proteopy.tl.peptide_proximity

tl;dr

Implements the proximity analysis from Bludau et al. 2021's COPF workflow: given a group of peptides that COPF has assigned to the same proteoform cluster, are those peptides closer together in the protein sequence than a random grouping of the same size would be? This is the second stage of the paper's proteoform-localization pipeline (COPF finds the groups; peptide_proximity asks whether each group is spatially coherent), and ProteoPy previously had no equivalent — the mouse tissue benchmark could only be reproduced up to the paper's 63 significant proteins, not past them.

The new function is validated against CCprofiler's own reference implementation rather than against ProteoPy's prior behavior, so the numbers in this PR are checked against the paper's actual pipeline, not against an assumption about what ProteoPy used to do.

Background: algorithm and assumptions

The analysis operates per (protein_id, cluster_id) group produced by COPF. For each group, it computes a proximity score from the peptides' sequence positions and tests that score against a null distribution built by permuting peptide-to-cluster assignments within the protein. This produces an empirical p-value per group, which is then aggregated to a per-protein statistic — the quantity the paper's significance calls are actually based on.

A few properties of the reference algorithm are preserved deliberately, because reproducing the paper means reproducing its exact conventions, not just its intent:

  • The noise cluster (COPF's catch-all for unassigned peptides) is tested like any other cluster, rather than excluded.
  • The proximity score's normalization mixes cluster size with protein-wide peptide ranks — this coupling is part of the reference definition, not an approximation of it.
  • P-values are "add-one" empirical p-values (i.e. (hits + 1) / (n + 1)), not raw permutation fractions.
  • Peptides whose sequence position can't be resolved are ranked first, matching the ordering behavior of data.table's setkey in the reference R implementation.
  • Not every protein is eligible for a test — proteins need at least two distinct non-noise clusters for "proximity between clusters" to be a meaningful question at all. Ineligible proteins are left NaN rather than assigned a placeholder value.

Two points where this implementation deliberately departs from the reference, both documented in the module docstring so they don't read as bugs later:

  • A single-peptide cluster has no standard deviation, so its score is NaN here instead of the reference's placeholder p-value of 1. This is inert for downstream results: a p-value of 1 could never be selected as a protein's minimum, and the per-protein aggregate simply skips a missing cluster.
  • Permutations are drawn from NumPy rather than R, re-seeded per peptide count to mirror the reference's per-protein set.seed(123). Because R's sample() stream isn't reproducible from Python, individual p-values differ by the sampling noise of two independent 1,000-draw estimates — but every discrete significance call matches exactly.

Implementation

pr.tl.peptide_proximity() resolves peptide sequence positions from a FASTA file at call time, reusing the same locator that pp.summarize_peptides_by_neighbourhood_union already uses. Sharing that locator (rather than reimplementing position lookup) is intentional: it's the only way to guarantee the two functions can't silently drift apart on how a peptide's position is defined.

Output lands in six new .var columns, two families of three:

Per (protein_id, cluster_id) group (repeated across the group's peptides):

column meaning
peptide_proximity_pval classical empirical p-value
peptide_proximity_pseudo_pval counts only strictly-smaller permutations — the paper's "lowest possible p-value" criterion
peptide_proximity_pval_adj Benjamini-Hochberg over the per-group tests

The FASTA subset itself is verified rather than trusted: test_peptide_positions_from_fasta_vs_rcopf re-derives all 24,534 peptide positions from it and checks them against the reference's positions directly.

tests/data/ is now excluded from the trailing-whitespace, end-of-file-fixer, and mixed-line-ending pre-commit hooks (it was already excluded from black and pyupgrade). This directory holds reference output, where trailing whitespace can be meaningful data — an untested proteoform group in the reference location table is represented as a row with two empty trailing fields, and stripping that whitespace would silently turn a 4-field row into a 2-field one.

Full test results:

$ pytest tests/tl/test_peptide_proximity.py -q
39 passed

$ pytest tests/ -q
488 passed in 525.68s (0:08:45)

$ flake8 proteopy/tl/peptide_proximity.py tests/tl/test_peptide_proximity.py
(clean)

$ pylint --disable=all --enable=E,F --disable=E0401 $(git ls-files "*.py")
10.00/10

$ pre-commit run --files <changed>
all hooks passed

Two findings worth flagging for review

peptide_proximity_pval_adj is 1.0 for every test on this dataset, and this is a property of the data, not a bug in the arithmetic. BH's smallest adjusted value is min_j (n/j)·p_(j); here the p-value curve stays strictly above the j/n diagonal for every j < n and only touches 1 at j = n, so the empirical distribution never crosses under the uniform diagonal. Proximity is a genuinely sparse effect in this dataset — the paper flags 26 proteins out of roughly three thousand clusters — and no FDR threshold can declare significance from an excess that thin. Increasing n_permutations doesn't change this. The module docstring explains why pseudo_pval isn't a workaround either, and notes that unified_pval is a minimum over dependent tests, so it's a ranking statistic rather than an FDR-controlled quantity.

The paper's Results text and Fig. 6C swap which number goes with which criterion. Running the reference workflow gives 7 significant proteins under the classical criterion and 19 under the pseudo-only criterion — not 19 and 7, as the text and figure state. This direction is forced: the pseudo p-value can never exceed the classical one, so 19-classical/7-pseudo is not an achievable outcome. test_paper_sequence_proximity_result_vs_rcopf checks for 7-classical/19-pseudo, and the module docstring flags this discrepancy explicitly so a future reader doesn't "fix" it into matching the paper's swapped numbers.

Sequence proximity analysis for COPF proteoform clusters -- a
reimplementation of CCprofiler's `evaluateProteoformLocation`
(Bludau et al. 2021, branch `proteoformLocationMapping` @ 31a3043),
which is the downstream characterisation COPF applies to the
proteoform groups it detects: are a cluster's peptides closer
together in the protein sequence than a random grouping of the same
size?

Peptide positions are resolved from a FASTA inside the call, reusing
`pp.summarize_peptides_by_neighbourhood_union`'s locator so the two
functions cannot drift apart on how a peptide is placed.

Named `peptide_proximity`, not `proximity_analysis` and not the
reference's `evaluateProteoformLocation`, for consistency with
`pr.tl.differential_abundance` -- no `_analysis` suffix. Added to the
Sphinx `tl.rst` COPF listing alongside the other COPF steps.

## What lands in `.var`

Two families of three. Per cluster, repeated across every peptide of
the `(protein_id, cluster_id)` group:

- `peptide_proximity_pval` -- classical empirical p-value
- `peptide_proximity_pseudo_pval` -- counts only strictly smaller
  permutations; the paper's "lowest possible p-value" criterion
- `peptide_proximity_pval_adj` -- Benjamini-Hochberg over the tests

Per protein, repeated across all its peptides, as
`peptide_proximity_unified_*`: the minimum over the protein's
clusters, which is the quantity `getProteoformStats` thresholds. The
minimum skips clusters with no value, and a protein qualifies only
with at least two distinct non-noise clusters -- below two there is no
proteoform split to localise. Skipping rather than propagating is
load-bearing: an untested single-peptide *noise* cluster is a
placeholder the reference scores 1, and propagating it would suppress
a real finding in a sibling cluster. `unified_pval_adj` is BH over
`unified_pval` itself, one entry per protein, not the minimum of the
per-cluster adjusted values.

The per-test table and the parameters land in
`.uns['peptide_proximity']`.

## Reference behaviours reproduced on purpose

Documented in the module docstring: the noise cluster is tested like
any other cluster; the normalisation mixes cluster size with
protein-wide ranks; single-peptide clusters have no standard
deviation; p-values are add-one empirical; unresolved peptide
positions rank first, as `data.table`'s `setkey` puts them; and
ineligible proteins are left `NaN`, so not every row carries a value
(1,272 of 2,885 proteins are tested on the reference dataset).

Permutations are re-seeded per peptide count, reproducing R's
per-protein `set.seed(123)`; `random_state=None` means that seed, so
the default run is the one comparable with the publication. Ties
between the observed score and a permuted one decide the gap between
the two p-values, and comparing float scores misjudges them, so
`tie_arithmetic='exact'` compares an exact integer dispersion with
the same ordering instead.

A cluster of one peptide has no standard deviation, so it is left
`NaN` rather than given the placeholder p-value of 1 the reference
records. Nothing downstream changes: 1 could never be a protein's
minimum, and the unified statistics skip a missing cluster. On the
mouse tissue dataset the reference emits 283 such rows, every one of
them a noise cluster, and the published counts are identical either
way.

`min_clusters_per_protein=2` is a guard the reference lacks, on by
default and inert on the published analysis: it skips a protein whose
single cluster holds every peptide, where the score is exactly 1 and
the pseudo p-value hits its floor whatever the data say. Pass `1` for
the reference's behaviour.

## Tests

39 tests pin the whole chain against CCprofiler's own output for the
mouse tissue dataset: FASTA -> positions -> ranks -> per-cluster
permutation p-values -> the counts the paper reports.

- all 24,534 peptide positions equal to the reference's, including
  the one peptide it cannot locate and keeps
- the same 1,272 tested proteins exactly, and every group tested here
  is one CCprofiler tested
- the published split reproduced twice over, with identical protein
  sets: by aggregating the per-cluster columns, and read straight off
  the `unified_` columns
- p-values within the sampling noise of two independent 1,000-draw
  estimates, which is all that is achievable without R's `sample()`
  stream

Three reference assets added under `tests/data/mouse_tissue/`, with
provenance and checksums in a new README there: the reference
proximity p-values, the reference per-peptide proteoform assignment,
and a subset FASTA whose derived positions are checked against the
reference's for all 24,534 peptides rather than assumed equal.

`tests/data/` is now excluded from the `trailing-whitespace`,
`end-of-file-fixer` and `mixed-line-ending` hooks, as it already was
from `black` and `pyupgrade`. It holds reference output where trailing
whitespace is data: an untested proteoform group in the location table
is a row with two empty trailing fields, and stripping them turns a
4-field row into a 2-field one.

## Two findings worth knowing

`peptide_proximity_pval_adj` is 1.0 for every test on this dataset,
and that is a property of the data rather than of the arithmetic: the
BH curve `(n/j) * p_(j)` sits strictly above 1 for every `j < n` and
touches 1 at `j = n`, i.e. `p_(j) > j/n` throughout, so the empirical
distribution lies entirely below the uniform diagonal. Proximity is a
sparse effect -- the publication flags 26 proteins among some three
thousand clusters -- and no FDR level can declare an excess that thin.
Raising `n_permutations` does not help. The docstring records why
`pseudo_pval` is not a way round it either.

The paper's Results text and Fig. 6C attach its two proximity numbers
to the opposite criteria from the ones its own code computes: the
classical criterion selects 7 proteins and the pseudo-only criterion
19, not the reverse. The direction is forced, since the pseudo
p-value never exceeds the classical one. A run reporting 7 and 19 is
correct; see `test_paper_sequence_proximity_result_vs_rcopf`.

Full suite 488 passed. flake8 and pylint (errors only) clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@read-the-docs-community

Copy link
Copy Markdown

@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: 68ecf52adc

ℹ️ 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".

@idf-io idf-io left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Approved

@idf-io
idf-io merged commit 8e69d1a into main Sep 3, 2026
7 checks passed
@UKHD-NP UKHD-NP deleted a comment from chatgpt-codex-connector Bot Sep 3, 2026
@UKHD-NP UKHD-NP deleted a comment from chatgpt-codex-connector Bot Sep 3, 2026
@UKHD-NP UKHD-NP deleted a comment from chatgpt-codex-connector Bot Sep 3, 2026
@idf-io
idf-io deleted the feat/tl-proximity-analysis branch September 3, 2026 18:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant