Skip to content

Commit eca1559

Browse files
authored
Merge pull request #155 from pyranges/fix-join-overlaps-nonzero-index
Fix join_overlaps row duplication with non-default index
2 parents 820551c + a8962db commit eca1559

9 files changed

Lines changed: 116 additions & 55 deletions

File tree

CHANGELOG.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
# 1.3.9 (30.05.26)
2+
- fix `join_overlaps` duplicating rows in `left`/`outer` joins when the input index did not start at 0
3+
- preserve the input index in `join_overlaps` output (self for inner/left/outer, other for right), consistent with `overlap`/`nearest_ranges`
4+
15
# 1.3.8 (21.04.26)
26
- repo name changed from pyranges_1.x to pyranges1
37
- updated references to it

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ available at https://pyranges1.readthedocs.io/
2222
## Recent Changelog
2323

2424
```
25+
# 1.3.9 (30.05.26)
26+
- fix `join_overlaps` duplicating rows in `left`/`outer` joins when the input index did not start at 0
27+
- preserve the input index in `join_overlaps` output (self for inner/left/outer, other for right), consistent with `overlap`/`nearest_ranges`
28+
2529
# 1.3.8 (21.04.26)
2630
- repo name changed from pyranges_1.x to pyranges1
2731
- updated references to it

docs/how_to_map.rst

Lines changed: 29 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -424,27 +424,27 @@ by pairing (overlapping) CDS and exon intervals with the same ``Parent`` value:
424424
index | Chromosome Start End Strand Feature
425425
int64 | str int64 int64 str category
426426
------- --- --------------------- ------- ------- -------- ----------
427-
0 | rna-DGYR_LOCUS13733 1 382 + CDS
428-
1 | rna-DGYR_LOCUS13734 258 484 + CDS
429-
2 | rna-DGYR_LOCUS13734 484 625 + CDS
430-
3 | rna-DGYR_LOCUS13734 625 798 + CDS
427+
4 | rna-DGYR_LOCUS13733 1 382 + CDS
428+
11 | rna-DGYR_LOCUS13734 258 484 + CDS
429+
12 | rna-DGYR_LOCUS13734 484 625 + CDS
430+
13 | rna-DGYR_LOCUS13734 625 798 + CDS
431431
... | ... ... ... ... ...
432-
52 | rna-DGYR_LOCUS12552-2 139 237 + CDS
433-
53 | rna-DGYR_LOCUS12552-2 237 337 + CDS
434-
54 | rna-DGYR_LOCUS12552-2 337 520 + CDS
435-
55 | rna-DGYR_LOCUS12552-2 520 640 + CDS
432+
146 | rna-DGYR_LOCUS12552-2 139 237 + CDS
433+
147 | rna-DGYR_LOCUS12552-2 237 337 + CDS
434+
148 | rna-DGYR_LOCUS12552-2 337 520 + CDS
435+
149 | rna-DGYR_LOCUS12552-2 520 640 + CDS
436436
PyRanges with 56 rows, 5 columns, and 1 index columns.
437437
Contains 17 chromosomes and 1 strands.
438438

439439
>>> cds_local[cds_local.Chromosome == some_id]
440440
index | Chromosome Start End Strand Feature
441441
int64 | str int64 int64 str category
442442
------- --- --------------------- ------- ------- -------- ----------
443-
51 | rna-DGYR_LOCUS12552-2 97 139 + CDS
444-
52 | rna-DGYR_LOCUS12552-2 139 237 + CDS
445-
53 | rna-DGYR_LOCUS12552-2 237 337 + CDS
446-
54 | rna-DGYR_LOCUS12552-2 337 520 + CDS
447-
55 | rna-DGYR_LOCUS12552-2 520 640 + CDS
443+
145 | rna-DGYR_LOCUS12552-2 97 139 + CDS
444+
146 | rna-DGYR_LOCUS12552-2 139 237 + CDS
445+
147 | rna-DGYR_LOCUS12552-2 237 337 + CDS
446+
148 | rna-DGYR_LOCUS12552-2 337 520 + CDS
447+
149 | rna-DGYR_LOCUS12552-2 520 640 + CDS
448448
PyRanges with 5 rows, 5 columns, and 1 index columns.
449449
Contains 1 chromosomes and 1 strands.
450450

@@ -454,30 +454,30 @@ With the similar logic, we can easily map the start and stop codon positions to
454454
index | Chromosome Start End Strand Feature
455455
int64 | str int64 int64 str str
456456
------- --- --------------------- ------- ------- -------- ---------
457-
0 | rna-DGYR_LOCUS13733 1 4 + start
458-
1 | rna-DGYR_LOCUS13734 258 261 + start
459-
2 | rna-DGYR_LOCUS13734 484 487 + start
460-
3 | rna-DGYR_LOCUS13734 625 628 + start
457+
4 | rna-DGYR_LOCUS13733 1 4 + start
458+
11 | rna-DGYR_LOCUS13734 258 261 + start
459+
12 | rna-DGYR_LOCUS13734 484 487 + start
460+
13 | rna-DGYR_LOCUS13734 625 628 + start
461461
... | ... ... ... ... ...
462-
52 | rna-DGYR_LOCUS12552-2 139 142 + start
463-
53 | rna-DGYR_LOCUS12552-2 237 240 + start
464-
54 | rna-DGYR_LOCUS12552-2 337 340 + start
465-
55 | rna-DGYR_LOCUS12552-2 520 523 + start
462+
146 | rna-DGYR_LOCUS12552-2 139 142 + start
463+
147 | rna-DGYR_LOCUS12552-2 237 240 + start
464+
148 | rna-DGYR_LOCUS12552-2 337 340 + start
465+
149 | rna-DGYR_LOCUS12552-2 520 523 + start
466466
PyRanges with 56 rows, 5 columns, and 1 index columns.
467467
Contains 17 chromosomes and 1 strands.
468468

469469
>>> cds.slice_ranges(-3).assign(Feature='stop').map_to_local(exons, ref_on='Parent', match_by='Parent')
470470
index | Chromosome Start End Strand Feature
471471
int64 | str int64 int64 str str
472472
------- --- --------------------- ------- ------- -------- ---------
473-
0 | rna-DGYR_LOCUS13733 379 382 + stop
474-
1 | rna-DGYR_LOCUS13734 481 484 + stop
475-
2 | rna-DGYR_LOCUS13734 622 625 + stop
476-
3 | rna-DGYR_LOCUS13734 795 798 + stop
473+
4 | rna-DGYR_LOCUS13733 379 382 + stop
474+
11 | rna-DGYR_LOCUS13734 481 484 + stop
475+
12 | rna-DGYR_LOCUS13734 622 625 + stop
476+
13 | rna-DGYR_LOCUS13734 795 798 + stop
477477
... | ... ... ... ... ...
478-
52 | rna-DGYR_LOCUS12552-2 234 237 + stop
479-
53 | rna-DGYR_LOCUS12552-2 334 337 + stop
480-
54 | rna-DGYR_LOCUS12552-2 517 520 + stop
481-
55 | rna-DGYR_LOCUS12552-2 637 640 + stop
478+
146 | rna-DGYR_LOCUS12552-2 234 237 + stop
479+
147 | rna-DGYR_LOCUS12552-2 334 337 + stop
480+
148 | rna-DGYR_LOCUS12552-2 517 520 + stop
481+
149 | rna-DGYR_LOCUS12552-2 637 640 + stop
482482
PyRanges with 56 rows, 5 columns, and 1 index columns.
483483
Contains 17 chromosomes and 1 strands.

docs/tutorial.rst

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -572,7 +572,7 @@ of the overlapping intervals, similar to a SQL join operation:
572572
index | Chromosome Start End Strand ID Start_b End_b ID_b
573573
int64 | category int64 int64 category str int64 int64 str
574574
------- --- ----------------- ------- ------- ---------- ---------------- --------- ------- ----------------
575-
15 | CAJFCJ010000025.1 2755 3055 - cds-CAD5125115.1 2753 2851 cds-CAD5125114.1
575+
135 | CAJFCJ010000025.1 2755 3055 - cds-CAD5125115.1 2753 2851 cds-CAD5125114.1
576576
PyRanges with 1 rows, 8 columns, and 1 index columns.
577577
Contains 1 chromosomes and 1 strands.
578578

@@ -581,8 +581,8 @@ The object ``j`` contains the columns of both objects, with the suffix "_b" to d
581581
It may be a bit too wide for our taste. Let's just look at a few columns to understand the overlap:
582582

583583
>>> j[['ID', 'Start', 'End', 'ID_b', 'Start_b', 'End_b']]
584-
ID Start End ID_b Start_b End_b
585-
15 cds-CAD5125115.1 2755 3055 cds-CAD5125114.1 2753 2851
584+
ID Start End ID_b Start_b End_b
585+
135 cds-CAD5125115.1 2755 3055 cds-CAD5125114.1 2753 2851
586586

587587
Above, we used a pandas syntax to select columns. Because the returned object does not have all genomic location
588588
columns, it is a pandas DataFrame.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta"
99

1010
[project]
1111
name = "pyranges1"
12-
version = "1.3.8"
12+
version = "1.3.9"
1313
description = "GenomicRanges for Python."
1414
requires-python = ">=3.12.0"
1515
readme = "README.md"

pyranges1/core/pyranges_main.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1215,7 +1215,10 @@ def join_overlaps( # type: ignore[override]
12151215
12161216
Note
12171217
----
1218-
The indices of the two input PyRanges are not preserved in output.
1218+
The index of the output is taken from the primary side of the join: ``self`` for
1219+
"inner", "left" and "outer", and ``other`` for "right". As a result the index may
1220+
contain duplicates (when an interval has several overlaps) and, for "outer"/"right",
1221+
missing-side rows keep the index of the side they originate from.
12191222
The chromosome column from other will never be reported as it is always the same as in self.
12201223
Whether the strand column from other is reported depends on the strand_behavior.
12211224
@@ -1278,11 +1281,11 @@ def join_overlaps( # type: ignore[override]
12781281
index | Chromosome Start End Name Start_b End_b Name_b
12791282
int64 | str float64 float64 str float64 float64 str
12801283
------- --- ------------ --------- --------- --------- --------- --------- --------
1281-
1 | chr1 5 7 interval2 6 7 b
1284+
2 | chr1 5 7 interval2 6 7 b
12821285
0 | chr1 3 6 interval1 nan nan nan
12831286
1 | chr1 8 9 interval3 nan nan nan
12841287
0 | nan nan nan nan 1 2 a
1285-
PyRanges with 4 rows, 7 columns, and 1 index columns (with 2 index duplicates).
1288+
PyRanges with 4 rows, 7 columns, and 1 index columns (with 1 index duplicates).
12861289
Contains 1 chromosomes.
12871290
Invalid ranges:
12881291
* 1 starts or ends are nan. See indexes: 0

pyranges1/methods/join.py

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -33,39 +33,41 @@ def _both_dfs(
3333
)
3434
expected_columns = [*df.head(0).join(df2.head(0), how="inner", rsuffix=suffix).columns]
3535
df2.columns = expected_columns[df.shape[1] :]
36+
# _self_indexes / _other_indexes are positional (from the overlap engine). Take the
37+
# matching rows, then temporarily reset both indexes to a RangeIndex so the column-wise
38+
# join pairs them by position rather than by label.
3639
_df = df.take(_self_indexes) # type: ignore[arg-type]
37-
_df.index = pd.Index(np.arange(len(_df)))
40+
_df.index = pd.RangeIndex(len(_df))
3841
_df2 = pd.DataFrame(df2).take(_other_indexes) # type: ignore[arg-type]
39-
_df2.index = pd.Index(np.arange(len(_df2)))
42+
_df2.index = pd.RangeIndex(len(_df2))
4043
j = _df.join(_df2, how="inner")
44+
# Restore the original input index, like overlap()/nearest_ranges()/etc. The primary
45+
# side of the join keeps its index: self for inner/left/outer, other for right.
46+
j.index = df2.index[_other_indexes] if join_type == "right" else df.index[_self_indexes]
47+
4148
if join_type == "inner":
42-
j.index = pd.Index(_self_indexes)
4349
return j
4450

4551
if join_type == "left":
46-
missing_rows = _missing_rows_left(_self_indexes, df, j)
47-
return pd.concat([j, missing_rows])
52+
return pd.concat([j, _missing_rows_left(_self_indexes, df)])
4853

4954
if join_type == "right":
50-
missing_rows = _missing_rows_right(_other_indexes, df2, j)
51-
return pd.concat([j, missing_rows])
55+
return pd.concat([j, _missing_rows_right(_other_indexes, df2)])
5256

5357
if join_type == "outer":
54-
missing_rows_l = _missing_rows_left(_self_indexes, df, j)
55-
missing_rows_r = _missing_rows_right(_other_indexes, df2, j)
56-
return pd.concat([j, missing_rows_l, missing_rows_r])
58+
return pd.concat([j, _missing_rows_left(_self_indexes, df), _missing_rows_right(_other_indexes, df2)])
5759

5860
msg = f"Invalid join type: {join_type}"
5961
raise ValueError(msg)
6062

6163

62-
def _missing_rows_left(_self_indexes, df, j) -> pd.DataFrame:
63-
missing_indices_left = df.index.difference(_self_indexes)
64-
j.index = _self_indexes
65-
return df.reindex(missing_indices_left)
64+
def _missing_rows_left(_self_indexes, df) -> pd.DataFrame:
65+
# Rows of self without an overlap are the positions not present in _self_indexes.
66+
# df.take keeps their original index labels, preserving the input index.
67+
missing_positions = np.setdiff1d(np.arange(len(df)), _self_indexes)
68+
return df.take(missing_positions)
6669

6770

68-
def _missing_rows_right(_other_indexes, df2, j) -> pd.DataFrame:
69-
missing_indices_right = df2.index.difference(_other_indexes)
70-
j.index = _other_indexes
71-
return df2.reindex(missing_indices_right)
71+
def _missing_rows_right(_other_indexes, df2) -> pd.DataFrame:
72+
missing_positions = np.setdiff1d(np.arange(len(df2)), _other_indexes)
73+
return pd.DataFrame(df2).take(missing_positions)

pyranges1/range_frame/range_frame.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -366,7 +366,8 @@ def join_overlaps(
366366
-------
367367
RangeFrame
368368
A new RangeFrame containing the joined intervals with columns from both input RangeFrames.
369-
The indices of the input RangeFrames are not preserved in the output.
369+
The output keeps the index of the primary side of the join (``self`` for inner/left/outer
370+
joins, ``other`` for right joins), so it may contain duplicates.
370371
371372
Notes
372373
-----

tests/unit/test_join.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,55 @@
1+
import pandas as pd
2+
import pytest
13
from numpy import nan
24

35
import pyranges1 as pr
46

57

8+
@pytest.mark.parametrize("index", [[0, 1], [1, 0], [1, 2], [2, 1]])
9+
def test_join_left_nonzero_index(index) -> None:
10+
"""A left join must not depend on the labels of self's index (issue: rows duplicated
11+
when the left index did not start at 0). The original self index is also preserved,
12+
consistent with overlap()/nearest_ranges()."""
13+
left = pd.DataFrame(
14+
{"Chromosome": ["1", "2"], "Start": [100, 300], "End": [200, 400]},
15+
index=index,
16+
)
17+
right = pd.DataFrame({"Chromosome": ["1"], "Start": [150], "End": [160]})
18+
19+
j = pr.PyRanges(left).join_overlaps(pr.PyRanges(right), join_type="left")
20+
21+
expected = pr.PyRanges(
22+
{
23+
"Chromosome": ["1", "2"],
24+
"Start": [100, 300],
25+
"End": [200, 400],
26+
"Start_b": [150.0, nan],
27+
"End_b": [160.0, nan],
28+
},
29+
index=index,
30+
)
31+
assert j.equals(expected)
32+
33+
34+
def test_join_preserves_input_index() -> None:
35+
"""join_overlaps preserves the original input index (self for inner/left/outer,
36+
other for right), like the rest of the overlap-family methods."""
37+
left = pd.DataFrame(
38+
{"Chromosome": ["1", "2"], "Start": [100, 300], "End": [200, 400]},
39+
index=[10, 20],
40+
)
41+
right = pd.DataFrame({"Chromosome": ["1"], "Start": [150], "End": [160]}, index=[77])
42+
gl, gr = pr.PyRanges(left), pr.PyRanges(right)
43+
44+
assert gl.join_overlaps(gr, join_type="inner").index.tolist() == [10]
45+
assert gl.join_overlaps(gr, join_type="left").index.tolist() == [10, 20]
46+
assert gl.join_overlaps(gr, join_type="right").index.tolist() == [77]
47+
assert gl.join_overlaps(gr, join_type="outer").index.tolist() == [10, 20]
48+
# inputs must not be mutated
49+
assert left.index.tolist() == [10, 20]
50+
assert right.index.tolist() == [77]
51+
52+
653
def test_join_issue_4_right() -> None:
754
import numpy as np
855

0 commit comments

Comments
 (0)