Skip to content

Commit bbb1187

Browse files
authored
Add Core zFPKM Tests (#239)
1 parent 0df0142 commit bbb1187

5 files changed

Lines changed: 809 additions & 116 deletions

File tree

main/como/approx.py

Lines changed: 130 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,64 @@
11
from __future__ import annotations
22

33
from collections.abc import Callable, Sequence
4-
from typing import Any
4+
from typing import Literal, NamedTuple
55

66
import numpy as np
7+
import numpy.typing as npt
78

89

9-
def _coerce_to_float_array(a):
10-
"""Helper to ensure input is a 1D float array."""
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+
"""
1131
arr = np.asarray(a, dtype=float)
1232
if arr.ndim != 1:
1333
arr = arr.ravel()
1434
return arr
1535

1636

17-
def _regularize_values(x: np.ndarray, y: np.ndarray, ties, na_rm: bool) -> dict[str, Any]:
18-
"""Minimal reimplementation of R's regularize.values() used by approx().
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().
1945
2046
- Removes NA pairs if na_rm is True (else requires x to have no NA).
2147
- Sorts by x (stable).
2248
- Collapses duplicate x via `ties` aggregator.
23-
Returns dict with:
24-
x: sorted unique x
25-
y: aggregated y aligned with x
26-
not_na: boolean mask of y that are not NaN
27-
kept_na: True iff any NaN remained in y after regularization
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
2862
"""
2963
if na_rm:
3064
# Remove pairs where x or y is NA
@@ -39,8 +73,7 @@ def _regularize_values(x: np.ndarray, y: np.ndarray, ties, na_rm: bool) -> dict[
3973
kept_na = np.isnan(y).any()
4074

4175
if x.size == 0:
42-
# THIS IS THE CORRECTED LINE:
43-
return {"x": x, "y": y, "not_na": np.array([], dtype=bool), "kept_na": kept_na}
76+
return RegularizedArray(x=x, y=y, not_na=np.array([], dtype=bool), kept_na=kept_na)
4477

4578
# Use a stable sort (mergesort) to match R's order()
4679
order = np.argsort(x, kind="mergesort")
@@ -51,27 +84,26 @@ def _regularize_values(x: np.ndarray, y: np.ndarray, ties, na_rm: bool) -> dict[
5184
if callable(ties):
5285
agg_fn = ties
5386
else:
54-
ties_str = "mean" if ties is None else str(ties).lower()
55-
if ties_str in ("mean", "avg", "average"):
87+
# fmt: off
88+
if ties in ("mean", "avg", "average"):
5689
agg_fn = np.mean
57-
elif ties_str in ("first", "left"):
58-
90+
elif ties in ("first", "left"):
5991
def agg_fn(a):
6092
return a[0]
61-
elif ties_str in ("last", "right"):
62-
93+
elif ties in ("last", "right"):
6394
def agg_fn(a):
6495
return a[-1]
65-
elif ties_str == "min":
96+
elif ties == "min":
6697
agg_fn = np.min
67-
elif ties_str == "max":
98+
elif ties == "max":
6899
agg_fn = np.max
69-
elif ties_str == "median":
100+
elif ties == "median":
70101
agg_fn = np.median
71-
elif ties_str == "sum":
102+
elif ties == "sum":
72103
agg_fn = np.sum
73104
else:
74105
raise ValueError("Unsupported `ties`; use a callable or one of 'mean', 'first', 'last', 'min', 'max', 'median', 'sum'.")
106+
# fmt: on
75107

76108
# Find unique x values and their indices/counts
77109
unique_x, start_idx, counts = np.unique(x_sorted, return_index=True, return_counts=True)
@@ -86,8 +118,8 @@ def agg_fn(a):
86118

87119
not_na = ~np.isnan(y_agg)
88120
# Check if any NaNs remain in the *aggregated* y values
89-
kept_na_final = np.any(~not_na) if np.any(np.isnan(y_agg)) else False
90-
return {"x": unique_x, "y": y_agg, "not_na": not_na, "kept_na": kept_na_final}
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)
91123

92124

93125
def approx(
@@ -98,11 +130,11 @@ def approx(
98130
n: int = 50,
99131
yleft: float | None = None,
100132
yright: float | None = None,
101-
rule: int | Sequence[int] = 1,
133+
rule: int | Sequence[int] | npt.NDArray[int] = 1,
102134
f: float = 0.0,
103-
ties: str | Callable[[np.ndarray], float] = "mean",
135+
ties: Callable[[np.ndarray], float] | Literal["mean", "first", "last", "min", "max", "median", "sum"] = "mean",
104136
na_rm: bool = True,
105-
) -> dict[str, np.ndarray]:
137+
) -> Approx:
106138
"""Faithful Python port of R's `approx` function.
107139
108140
This implementation aims to replicate the behavior of R's `approx`
@@ -114,26 +146,18 @@ def approx(
114146
y: y-coordinates. If None, `x` is treated as `y` and `x` becomes `1..n`.
115147
xout: Points at which to interpolate.
116148
method: Interpolation method. "linear" (1) or "constant" (2).
117-
n: If `xout` is not provided, interpolation happens at `n`
118-
equally spaced points spanning the range of `x`.
119-
yleft: Value to use for extrapolation to the left.
120-
Defaults to `NA` (np.nan) if `rule` is 1.
121-
yright: Value to use for extrapolation to the right.
122-
Defaults to `NA` (np.nan) if `rule` is 1.
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.
123152
rule: Extrapolation rule.
124153
- 1: Return `np.nan` for points outside the `x` range.
125154
- 2: Use `yleft` and `yright` for points outside the range.
126-
f: For `method="constant"`, determines the split point.
127-
`f=0` is left-step, `f=1` is right-step, `f=0.5` is midpoint.
128-
ties: How to handle duplicate `x` values.
129-
Can be 'mean', 'first', 'last', 'min', 'max', 'median', 'sum',
130-
or a callable function.
131-
na_rm: If True, `NA` pairs are removed before interpolation.
132-
If False, `NA`s in `x` cause an error, `NA`s in `y`
133-
are propagated.
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.
134158
135159
Returns:
136-
dict with:
160+
`Approx` class with:
137161
- 'x': numpy array of xout used
138162
- 'y': numpy array of interpolated values
139163
"""
@@ -164,25 +188,24 @@ def approx(
164188
raise ValueError("invalid interpolation method")
165189

166190
# --- Rule normalization ---
167-
if isinstance(rule, list | tuple | np.ndarray):
168-
rlist = list(rule)
169-
if not (1 <= len(rlist) <= 2):
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):
170194
raise ValueError("`rule` must have length 1 or 2")
171-
if len(rlist) == 1:
172-
rlist = [rlist[0], rlist[0]]
195+
if len(rule_list) == 1:
196+
rule_list = [rule_list[0], rule_list[0]]
173197
else:
174-
rlist = [rule, rule]
198+
rule_list = [rule, rule]
175199

176-
# --- Regularize values ---
177200
# Sort by x, collapse ties, and handle NAs like R's regularize.values()
178-
r = _regularize_values(x_arr, y_arr, ties, na_rm)
179-
x_reg = r["x"]
180-
y_reg = r["y"]
181-
not_na_mask = r["not_na"]
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
182205
# no_na is True if we don't have to worry about NAs in y_reg
183-
no_na = na_rm or (not r["kept_na"])
206+
no_na: bool = na_rm or (not r.kept_na)
184207
# nx is the number of *valid* (non-NA) points for interpolation
185-
nx = x_reg.size if no_na else int(np.count_nonzero(not_na_mask))
208+
nx: int = x_reg.size if no_na else int(np.count_nonzero(not_na_mask))
186209

187210
if np.isnan(nx):
188211
raise ValueError("invalid length(x)")
@@ -194,98 +217,98 @@ def approx(
194217
if nx == 0:
195218
raise ValueError("zero non-NA points")
196219

197-
# --- Set extrapolation values (yleft/yright) ---
220+
# set extrapolation values (yleft/yright)
198221
# This logic matches R's C code (R_approxtest)
199222
if no_na:
200-
y_first = y_reg[0]
201-
y_last = y_reg[-1]
223+
y_first: float = y_reg[0]
224+
y_last: float = y_reg[-1]
202225
else:
203226
# Find first and last non-NA y values
204-
y_valid = y_reg[not_na_mask]
205-
y_first = y_valid[0]
206-
y_last = y_valid[-1]
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]
207230

208-
yleft_val = (np.nan if int(rlist[0]) == 1 else y_first) if yleft is None else float(yleft)
209-
yright_val = (np.nan if int(rlist[1]) == 1 else y_last) if yright is None else float(yright)
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)
210233

211-
# --- Fabricate xout if missing ---
234+
# fabricate xout if it is missing
212235
if xout is None:
213236
if n <= 0:
214237
raise ValueError("'approx' requires n >= 1")
215238
if no_na:
216-
x_first = x_reg[0]
217-
x_last = x_reg[nx - 1]
239+
x_first: float = x_reg[0]
240+
x_last: float = x_reg[nx - 1]
218241
else:
219-
x_valid = x_reg[not_na_mask]
220-
x_first = x_valid[0]
221-
x_last = x_valid[-1]
222-
xout_arr = np.linspace(x_first, x_last, num=int(n), dtype=float)
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)
223246
else:
224-
xout_arr = _coerce_to_float_array(xout)
247+
xout_arr: npt.NDArray[float] = _coerce_to_float_array(xout)
225248

226-
# --- C_ApproxTest checks ---
249+
# replicate R's C_ApproxTest checks
227250
if method_code == 2 and (not np.isfinite(f) or f < 0.0 or f > 1.0):
228-
raise ValueError("approx(): invalid f value")
251+
raise ValueError("approx(): invalid f value; with `method=2`, `f` must be in the range [0,1] (inclusive) or NA")
229252
if not no_na:
230253
# R re-filters x and y here if NAs remained
231-
x_reg = x_reg[not_na_mask]
232-
y_reg = y_reg[not_na_mask]
254+
x_reg: npt.NDArray[float] = x_reg[not_na_mask]
255+
y_reg: npt.NDArray[float] = y_reg[not_na_mask]
233256

234-
# --- Interpolation ---
235-
# This section vectorized the logic from R's approx1 and R_approxfun
236-
yout = np.empty_like(xout_arr, dtype=float)
237-
mask_nan_xout = np.isnan(xout_arr)
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)
238261
yout[mask_nan_xout] = np.nan
239-
mask_valid = ~mask_nan_xout
262+
mask_valid: npt.NDArray[bool] = ~mask_nan_xout
240263

241264
if x_reg.size == 0:
242265
# No valid points to interpolate against
243266
yout[mask_valid] = np.nan
244-
return {"x": xout_arr, "y": yout}
267+
return Approx(x=xout_arr, y=yout)
245268

246-
xv = xout_arr[mask_valid]
247-
left_mask = xv < x_reg[0]
248-
right_mask = xv > x_reg[-1]
249-
mid_mask = ~(left_mask | right_mask)
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)
250273

251-
res = np.empty_like(xv)
274+
res: npt.NDArray[float] = np.empty_like(xv, dtype=float)
252275
res[left_mask] = yleft_val
253276
res[right_mask] = yright_val
254277

255278
if np.any(mid_mask):
256-
xm = xv[mid_mask]
279+
xm: npt.NDArray[float] = xv[mid_mask]
257280

258281
# Find indices
259282
# j_right[k] = index of first x_reg > xm[k]
260-
j_right = np.searchsorted(x_reg, xm, side="right")
283+
j_right: npt.NDArray[int] = np.searchsorted(x_reg, xm, side="right")
261284
# j_left[k] = index of first x_reg >= xm[k]
262-
j_left = np.searchsorted(x_reg, xm, side="left")
285+
j_left: npt.NDArray[int] = np.searchsorted(x_reg, xm, side="left")
263286

264287
# Points that exactly match an x_reg value
265-
eq_mask = j_left != j_right
288+
eq_mask: npt.NDArray[bool] = j_left != j_right
266289
# Points that fall between x_reg values
267-
in_interval_mask = ~eq_mask
290+
in_interval_mask: npt.NDArray[bool] = ~eq_mask
268291

269-
res_mid = np.empty_like(xm)
292+
res_mid: npt.NDArray[float] = np.empty_like(xm, dtype=float)
270293

271294
if np.any(eq_mask):
272295
# For exact matches, use the corresponding y_reg value
273296
# R's C code uses the 'j_left' index here
274-
res_mid[eq_mask] = y_reg[j_left[eq_mask]]
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
275298

276299
if np.any(in_interval_mask):
277300
# R's C code sets i = j-1, where j is the 'right' index
278-
j = j_right[in_interval_mask]
279-
i = j - 1
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
280303

281304
# Get the surrounding x and y values
282-
xi = x_reg[i]
283-
xj = x_reg[j]
284-
yi = y_reg[i]
285-
yj = y_reg[j]
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]
286309

287310
if method_code == 1: # linear
288-
t = (xm[in_interval_mask] - xi) / (xj - xi)
311+
t: npt.NDArray[float] = (xm[in_interval_mask] - xi) / (xj - xi)
289312
res_mid[in_interval_mask] = yi + (yj - yi) * t
290313
else: # constant
291314
# This matches R_approxfun's constant logic
@@ -294,17 +317,17 @@ def approx(
294317
elif f == 1.0:
295318
res_mid[in_interval_mask] = yj
296319
else:
297-
# R computes (1-f)*yi + f*yj, but carefully
298-
f1 = 1.0 - f
299-
f2 = f
300-
part = np.zeros_like(yi)
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)
301324
if f1 != 0.0:
302-
part = part + yi * f1
325+
part: npt.NDArray[float] = part + yi * f1
303326
if f2 != 0.0:
304-
part = part + yj * f2
327+
part: npt.NDArray[float] = part + yj * f2
305328
res_mid[in_interval_mask] = part
306329

307330
res[mid_mask] = res_mid
308331

309332
yout[mask_valid] = res
310-
return {"x": xout_arr, "y": yout}
333+
return Approx(x=xout_arr, y=yout)

0 commit comments

Comments
 (0)