Skip to content

Commit e200897

Browse files
committed
Merge remote-tracking branch 'origin/develop' into develop
# Conflicts: # main/como/rnaseq_gen.py # main/como/rnaseq_preprocess.py
2 parents 4a44590 + bbb1187 commit e200897

97 files changed

Lines changed: 3304 additions & 592 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

main/como/approx.py

Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import Callable, Sequence
4+
from typing import Literal, NamedTuple
5+
6+
import numpy as np
7+
import numpy.typing as npt
8+
9+
10+
class RegularizedArray(NamedTuple):
11+
x: npt.NDArray[np.number]
12+
y: npt.NDArray[np.number]
13+
not_na: npt.NDArray[bool]
14+
kept_na: bool
15+
16+
17+
class Approx(NamedTuple):
18+
x: npt.NDArray[float]
19+
y: npt.NDArray[float]
20+
21+
22+
def _coerce_to_float_array(a: Sequence[int | float | np.number]):
23+
"""Coerce input to a 1D float array.
24+
25+
Args:
26+
a: the array to coerce
27+
28+
Returns:
29+
A floating point 1D array.
30+
"""
31+
arr = np.asarray(a, dtype=float)
32+
if arr.ndim != 1:
33+
arr = arr.ravel()
34+
return arr
35+
36+
37+
def _regularize_values(
38+
x: npt.NDArray[np.number],
39+
y: npt.NDArray[np.number],
40+
*,
41+
na_rm: bool,
42+
ties: Callable[[np.ndarray], float] | Literal["mean", "first", "last", "min", "max", "median", "sum"] = "mean",
43+
) -> RegularizedArray:
44+
"""Reimplementation of R's regularize.values() used by approx().
45+
46+
- Removes NA pairs if na_rm is True (else requires x to have no NA).
47+
- Sorts by x (stable).
48+
- Collapses duplicate x via `ties` aggregator.
49+
50+
Args:
51+
x: the x values to regularize
52+
y: ties: the corresponding y values
53+
na_rm: should NA values be removed?
54+
ties: how to handle duplicate x values; can be a string or a callable
55+
56+
Returns:
57+
A NamedTuple with:
58+
- x: sorted unique x
59+
- y: aggregated y aligned with x
60+
- not_na: boolean mask of y that are not NaN
61+
- kept_na: True iff any NaN remained in y after regularization
62+
"""
63+
if na_rm:
64+
# Remove pairs where x or y is NA
65+
keep = ~np.isnan(x) & ~np.isnan(y)
66+
x = x[keep]
67+
y = y[keep]
68+
kept_na = False
69+
else:
70+
# R's C code errors if na_rm=False and any x is NA
71+
if np.isnan(x).any():
72+
raise ValueError("approx(x,y, .., na.rm=False): NA values in x are not allowed")
73+
kept_na = np.isnan(y).any()
74+
75+
if x.size == 0:
76+
return RegularizedArray(x=x, y=y, not_na=np.array([], dtype=bool), kept_na=kept_na)
77+
78+
# Use a stable sort (mergesort) to match R's order()
79+
order = np.argsort(x, kind="mergesort")
80+
x_sorted = x[order]
81+
y_sorted = y[order]
82+
83+
# Handle the 'ties' argument
84+
if callable(ties):
85+
agg_fn = ties
86+
else:
87+
# fmt: off
88+
if ties in ("mean", "avg", "average"):
89+
agg_fn = np.mean
90+
elif ties in ("first", "left"):
91+
def agg_fn(a):
92+
return a[0]
93+
elif ties in ("last", "right"):
94+
def agg_fn(a):
95+
return a[-1]
96+
elif ties == "min":
97+
agg_fn = np.min
98+
elif ties == "max":
99+
agg_fn = np.max
100+
elif ties == "median":
101+
agg_fn = np.median
102+
elif ties == "sum":
103+
agg_fn = np.sum
104+
else:
105+
raise ValueError("Unsupported `ties`; use a callable or one of 'mean', 'first', 'last', 'min', 'max', 'median', 'sum'.")
106+
# fmt: on
107+
108+
# Find unique x values and their indices/counts
109+
unique_x, start_idx, counts = np.unique(x_sorted, return_index=True, return_counts=True)
110+
111+
# Aggregate y values for tied x values
112+
y_agg = np.empty(unique_x.shape, dtype=float)
113+
for k, (start, count) in enumerate(zip(start_idx, counts, strict=True)):
114+
seg = y_sorted[start : start + count]
115+
# Note: aggregators like np.mean will return NaN if `seg` contains NaN,
116+
# matching R's default (mean(..., na.rm = FALSE)).
117+
y_agg[k] = agg_fn(seg)
118+
119+
not_na = ~np.isnan(y_agg)
120+
# Check if any NaNs remain in the *aggregated* y values
121+
kept_na_final = bool(np.any(~not_na) if np.any(np.isnan(y_agg)) else False)
122+
return RegularizedArray(x=unique_x, y=y_agg, not_na=not_na, kept_na=kept_na_final)
123+
124+
125+
def approx(
126+
x: Sequence[float],
127+
y: Sequence[float] | None = None,
128+
xout: Sequence[float] | None = None,
129+
method: str | int = "linear",
130+
n: int = 50,
131+
yleft: float | None = None,
132+
yright: float | None = None,
133+
rule: int | Sequence[int] | npt.NDArray[int] = 1,
134+
f: float = 0.0,
135+
ties: Callable[[np.ndarray], float] | Literal["mean", "first", "last", "min", "max", "median", "sum"] = "mean",
136+
na_rm: bool = True,
137+
) -> Approx:
138+
"""Faithful Python port of R's `approx` function.
139+
140+
This implementation aims to replicate the behavior of R's `approx`
141+
function, including its handling of `ties`, `rule`, `na_rm`, and
142+
`method`.
143+
144+
Args:
145+
x: Coordinates, or y-values if `y` is None.
146+
y: y-coordinates. If None, `x` is treated as `y` and `x` becomes `1..n`.
147+
xout: Points at which to interpolate.
148+
method: Interpolation method. "linear" (1) or "constant" (2).
149+
n: If `xout` is not provided, interpolation happens at `n` equally spaced points spanning the range of `x`.
150+
yleft: Value to use for extrapolation to the left. Defaults to `NA` (np.nan) if `rule` is 1.
151+
yright: Value to use for extrapolation to the right. Defaults to `NA` (np.nan) if `rule` is 1.
152+
rule: Extrapolation rule.
153+
- 1: Return `np.nan` for points outside the `x` range.
154+
- 2: Use `yleft` and `yright` for points outside the range.
155+
f: For `method="constant"`, determines the split point. `f=0` is left-step, `f=1` is right-step, `f=0.5` is midpoint.
156+
ties: How to handle duplicate `x` values. Can be 'mean', 'first', 'last', 'min', 'max', 'median', 'sum', or a callable function.
157+
na_rm: If True, `NA` pairs are removed before interpolation. If False, `NA`s in `x` cause an error, `NA`s in `y` are propagated.
158+
159+
Returns:
160+
`Approx` class with:
161+
- 'x': numpy array of xout used
162+
- 'y': numpy array of interpolated values
163+
"""
164+
# One-argument form: approx(y) -> x := 1..n, y := y
165+
if y is None:
166+
y_arr = _coerce_to_float_array(x)
167+
x_arr = np.arange(1.0, y_arr.size + 1.0, dtype=float)
168+
else:
169+
x_arr = _coerce_to_float_array(x)
170+
y_arr = _coerce_to_float_array(y)
171+
if x_arr.size != y_arr.size:
172+
raise ValueError("x and y must have same length")
173+
174+
# --- Method normalization ---
175+
# (matches R's pmatch() result: 1=linear, 2=constant)
176+
if isinstance(method, str):
177+
m = method.strip().lower()
178+
if m.startswith("lin"):
179+
method_code = 1
180+
elif m.startswith("const"):
181+
method_code = 2
182+
else:
183+
raise ValueError("invalid interpolation method")
184+
else:
185+
if method in (1, 2):
186+
method_code = int(method)
187+
else:
188+
raise ValueError("invalid interpolation method")
189+
190+
# --- Rule normalization ---
191+
if isinstance(rule, Sequence | np.ndarray):
192+
rule_list: list[int] = list(rule) # type: ignore[invalid-argument-type] # This is a valid argument type, not sure why ty is upset
193+
if not (1 <= len(rule_list) <= 2):
194+
raise ValueError("`rule` must have length 1 or 2")
195+
if len(rule_list) == 1:
196+
rule_list = [rule_list[0], rule_list[0]]
197+
else:
198+
rule_list = [rule, rule]
199+
200+
# Sort by x, collapse ties, and handle NAs like R's regularize.values()
201+
r: RegularizedArray = _regularize_values(x_arr, y_arr, na_rm=na_rm, ties=ties)
202+
x_reg: npt.NDArray[float] = r.x
203+
y_reg: npt.NDArray[float] = r.y
204+
not_na_mask: npt.NDArray[bool] = r.not_na
205+
# no_na is True if we don't have to worry about NAs in y_reg
206+
no_na: bool = na_rm or (not r.kept_na)
207+
# nx is the number of *valid* (non-NA) points for interpolation
208+
nx: int = x_reg.size if no_na else int(np.count_nonzero(not_na_mask))
209+
210+
if np.isnan(nx):
211+
raise ValueError("invalid length(x)")
212+
213+
# R's C_ApproxTest logic
214+
if nx <= 1:
215+
if method_code == 1:
216+
raise ValueError("need at least two non-NA values to interpolate")
217+
if nx == 0:
218+
raise ValueError("zero non-NA points")
219+
220+
# set extrapolation values (yleft/yright)
221+
# This logic matches R's C code (R_approxtest)
222+
if no_na:
223+
y_first: float = y_reg[0]
224+
y_last: float = y_reg[-1]
225+
else:
226+
# Find first and last non-NA y values
227+
y_valid: npt.NDArray[float] = y_reg[not_na_mask]
228+
y_first: float = y_valid[0]
229+
y_last: float = y_valid[-1]
230+
231+
yleft_val: float = (np.nan if int(rule_list[0]) == 1 else y_first) if yleft is None else float(yleft)
232+
yright_val: float = (np.nan if int(rule_list[1]) == 1 else y_last) if yright is None else float(yright)
233+
234+
# fabricate xout if it is missing
235+
if xout is None:
236+
if n <= 0:
237+
raise ValueError("'approx' requires n >= 1")
238+
if no_na:
239+
x_first: float = x_reg[0]
240+
x_last: float = x_reg[nx - 1]
241+
else:
242+
x_valid: npt.NDArray[float] = x_reg[not_na_mask]
243+
x_first: float = x_valid[0]
244+
x_last: float = x_valid[-1]
245+
xout_arr: npt.NDArray[float] = np.linspace(x_first, x_last, num=int(n), dtype=float)
246+
else:
247+
xout_arr: npt.NDArray[float] = _coerce_to_float_array(xout)
248+
249+
# replicate R's C_ApproxTest checks
250+
if method_code == 2 and (not np.isfinite(f) or f < 0.0 or f > 1.0):
251+
raise ValueError("approx(): invalid f value; with `method=2`, `f` must be in the range [0,1] (inclusive) or NA")
252+
if not no_na:
253+
# R re-filters x and y here if NAs remained
254+
x_reg: npt.NDArray[float] = x_reg[not_na_mask]
255+
y_reg: npt.NDArray[float] = y_reg[not_na_mask]
256+
257+
# perform interpolation
258+
# this section is a vectorized form of the logic from R's approx1 and R_approxfun
259+
yout: npt.NDArray[float] = np.empty_like(xout_arr, dtype=float)
260+
mask_nan_xout: npt.NDArray[bool] = np.isnan(xout_arr)
261+
yout[mask_nan_xout] = np.nan
262+
mask_valid: npt.NDArray[bool] = ~mask_nan_xout
263+
264+
if x_reg.size == 0:
265+
# No valid points to interpolate against
266+
yout[mask_valid] = np.nan
267+
return Approx(x=xout_arr, y=yout)
268+
269+
xv: npt.NDArray[float] = xout_arr[mask_valid]
270+
left_mask: npt.NDArray[bool] = xv < x_reg[0]
271+
right_mask: npt.NDArray[bool] = xv > x_reg[-1]
272+
mid_mask: npt.NDArray[bool] = ~(left_mask | right_mask)
273+
274+
res: npt.NDArray[float] = np.empty_like(xv, dtype=float)
275+
res[left_mask] = yleft_val
276+
res[right_mask] = yright_val
277+
278+
if np.any(mid_mask):
279+
xm: npt.NDArray[float] = xv[mid_mask]
280+
281+
# Find indices
282+
# j_right[k] = index of first x_reg > xm[k]
283+
j_right: npt.NDArray[int] = np.searchsorted(x_reg, xm, side="right")
284+
# j_left[k] = index of first x_reg >= xm[k]
285+
j_left: npt.NDArray[int] = np.searchsorted(x_reg, xm, side="left")
286+
287+
# Points that exactly match an x_reg value
288+
eq_mask: npt.NDArray[bool] = j_left != j_right
289+
# Points that fall between x_reg values
290+
in_interval_mask: npt.NDArray[bool] = ~eq_mask
291+
292+
res_mid: npt.NDArray[float] = np.empty_like(xm, dtype=float)
293+
294+
if np.any(eq_mask):
295+
# For exact matches, use the corresponding y_reg value
296+
# R's C code uses the 'j_left' index here
297+
res_mid[eq_mask] = y_reg[j_left[eq_mask]] # type: ignore[non-subscriptable] # we know this is of type npt.NDArray[float], not sure why ty is upset
298+
299+
if np.any(in_interval_mask):
300+
# R's C code sets i = j-1, where j is the 'right' index
301+
j: npt.NDArray[float] = j_right[in_interval_mask] # type: ignore[non-subscriptable] # we know this is of type npt.NDArray[float], not sure why ty is upset
302+
i: npt.NDArray[float] = j - 1
303+
304+
# Get the surrounding x and y values
305+
xi: npt.NDArray[float] = x_reg[i]
306+
xj: npt.NDArray[float] = x_reg[j]
307+
yi: npt.NDArray[float] = y_reg[i]
308+
yj: npt.NDArray[float] = y_reg[j]
309+
310+
if method_code == 1: # linear
311+
t: npt.NDArray[float] = (xm[in_interval_mask] - xi) / (xj - xi)
312+
res_mid[in_interval_mask] = yi + (yj - yi) * t
313+
else: # constant
314+
# This matches R_approxfun's constant logic
315+
if f == 0.0:
316+
res_mid[in_interval_mask] = yi
317+
elif f == 1.0:
318+
res_mid[in_interval_mask] = yj
319+
else:
320+
# computes R's (1-f)*yi + f*yj
321+
f1: float = float(1.0 - f)
322+
f2: float = float(f)
323+
part: npt.NDArray[float] = np.zeros_like(yi, dtype=float)
324+
if f1 != 0.0:
325+
part: npt.NDArray[float] = part + yi * f1
326+
if f2 != 0.0:
327+
part: npt.NDArray[float] = part + yj * f2
328+
res_mid[in_interval_mask] = part
329+
330+
res[mid_mask] = res_mid
331+
332+
yout[mask_valid] = res
333+
return Approx(x=xout_arr, y=yout)

0 commit comments

Comments
 (0)