Skip to content

Commit 0df0142

Browse files
authored
Fix zFPKM Calculations (#238)
1 parent f097305 commit 0df0142

27 files changed

Lines changed: 1526 additions & 592 deletions

main/como/approx.py

Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import Callable, Sequence
4+
from typing import Any
5+
6+
import numpy as np
7+
8+
9+
def _coerce_to_float_array(a):
10+
"""Helper to ensure input is a 1D float array."""
11+
arr = np.asarray(a, dtype=float)
12+
if arr.ndim != 1:
13+
arr = arr.ravel()
14+
return arr
15+
16+
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().
19+
20+
- Removes NA pairs if na_rm is True (else requires x to have no NA).
21+
- Sorts by x (stable).
22+
- 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
28+
"""
29+
if na_rm:
30+
# Remove pairs where x or y is NA
31+
keep = ~np.isnan(x) & ~np.isnan(y)
32+
x = x[keep]
33+
y = y[keep]
34+
kept_na = False
35+
else:
36+
# R's C code errors if na_rm=False and any x is NA
37+
if np.isnan(x).any():
38+
raise ValueError("approx(x,y, .., na.rm=False): NA values in x are not allowed")
39+
kept_na = np.isnan(y).any()
40+
41+
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}
44+
45+
# Use a stable sort (mergesort) to match R's order()
46+
order = np.argsort(x, kind="mergesort")
47+
x_sorted = x[order]
48+
y_sorted = y[order]
49+
50+
# Handle the 'ties' argument
51+
if callable(ties):
52+
agg_fn = ties
53+
else:
54+
ties_str = "mean" if ties is None else str(ties).lower()
55+
if ties_str in ("mean", "avg", "average"):
56+
agg_fn = np.mean
57+
elif ties_str in ("first", "left"):
58+
59+
def agg_fn(a):
60+
return a[0]
61+
elif ties_str in ("last", "right"):
62+
63+
def agg_fn(a):
64+
return a[-1]
65+
elif ties_str == "min":
66+
agg_fn = np.min
67+
elif ties_str == "max":
68+
agg_fn = np.max
69+
elif ties_str == "median":
70+
agg_fn = np.median
71+
elif ties_str == "sum":
72+
agg_fn = np.sum
73+
else:
74+
raise ValueError("Unsupported `ties`; use a callable or one of 'mean', 'first', 'last', 'min', 'max', 'median', 'sum'.")
75+
76+
# Find unique x values and their indices/counts
77+
unique_x, start_idx, counts = np.unique(x_sorted, return_index=True, return_counts=True)
78+
79+
# Aggregate y values for tied x values
80+
y_agg = np.empty(unique_x.shape, dtype=float)
81+
for k, (start, count) in enumerate(zip(start_idx, counts, strict=True)):
82+
seg = y_sorted[start : start + count]
83+
# Note: aggregators like np.mean will return NaN if `seg` contains NaN,
84+
# matching R's default (mean(..., na.rm = FALSE)).
85+
y_agg[k] = agg_fn(seg)
86+
87+
not_na = ~np.isnan(y_agg)
88+
# 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}
91+
92+
93+
def approx(
94+
x: Sequence[float],
95+
y: Sequence[float] | None = None,
96+
xout: Sequence[float] | None = None,
97+
method: str | int = "linear",
98+
n: int = 50,
99+
yleft: float | None = None,
100+
yright: float | None = None,
101+
rule: int | Sequence[int] = 1,
102+
f: float = 0.0,
103+
ties: str | Callable[[np.ndarray], float] = "mean",
104+
na_rm: bool = True,
105+
) -> dict[str, np.ndarray]:
106+
"""Faithful Python port of R's `approx` function.
107+
108+
This implementation aims to replicate the behavior of R's `approx`
109+
function, including its handling of `ties`, `rule`, `na_rm`, and
110+
`method`.
111+
112+
Args:
113+
x: Coordinates, or y-values if `y` is None.
114+
y: y-coordinates. If None, `x` is treated as `y` and `x` becomes `1..n`.
115+
xout: Points at which to interpolate.
116+
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.
123+
rule: Extrapolation rule.
124+
- 1: Return `np.nan` for points outside the `x` range.
125+
- 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.
134+
135+
Returns:
136+
dict with:
137+
- 'x': numpy array of xout used
138+
- 'y': numpy array of interpolated values
139+
"""
140+
# One-argument form: approx(y) -> x := 1..n, y := y
141+
if y is None:
142+
y_arr = _coerce_to_float_array(x)
143+
x_arr = np.arange(1.0, y_arr.size + 1.0, dtype=float)
144+
else:
145+
x_arr = _coerce_to_float_array(x)
146+
y_arr = _coerce_to_float_array(y)
147+
if x_arr.size != y_arr.size:
148+
raise ValueError("x and y must have same length")
149+
150+
# --- Method normalization ---
151+
# (matches R's pmatch() result: 1=linear, 2=constant)
152+
if isinstance(method, str):
153+
m = method.strip().lower()
154+
if m.startswith("lin"):
155+
method_code = 1
156+
elif m.startswith("const"):
157+
method_code = 2
158+
else:
159+
raise ValueError("invalid interpolation method")
160+
else:
161+
if method in (1, 2):
162+
method_code = int(method)
163+
else:
164+
raise ValueError("invalid interpolation method")
165+
166+
# --- Rule normalization ---
167+
if isinstance(rule, list | tuple | np.ndarray):
168+
rlist = list(rule)
169+
if not (1 <= len(rlist) <= 2):
170+
raise ValueError("`rule` must have length 1 or 2")
171+
if len(rlist) == 1:
172+
rlist = [rlist[0], rlist[0]]
173+
else:
174+
rlist = [rule, rule]
175+
176+
# --- Regularize values ---
177+
# 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"]
182+
# 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"])
184+
# 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))
186+
187+
if np.isnan(nx):
188+
raise ValueError("invalid length(x)")
189+
190+
# R's C_ApproxTest logic
191+
if nx <= 1:
192+
if method_code == 1:
193+
raise ValueError("need at least two non-NA values to interpolate")
194+
if nx == 0:
195+
raise ValueError("zero non-NA points")
196+
197+
# --- Set extrapolation values (yleft/yright) ---
198+
# This logic matches R's C code (R_approxtest)
199+
if no_na:
200+
y_first = y_reg[0]
201+
y_last = y_reg[-1]
202+
else:
203+
# 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]
207+
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)
210+
211+
# --- Fabricate xout if missing ---
212+
if xout is None:
213+
if n <= 0:
214+
raise ValueError("'approx' requires n >= 1")
215+
if no_na:
216+
x_first = x_reg[0]
217+
x_last = x_reg[nx - 1]
218+
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)
223+
else:
224+
xout_arr = _coerce_to_float_array(xout)
225+
226+
# --- C_ApproxTest checks ---
227+
if method_code == 2 and (not np.isfinite(f) or f < 0.0 or f > 1.0):
228+
raise ValueError("approx(): invalid f value")
229+
if not no_na:
230+
# 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]
233+
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)
238+
yout[mask_nan_xout] = np.nan
239+
mask_valid = ~mask_nan_xout
240+
241+
if x_reg.size == 0:
242+
# No valid points to interpolate against
243+
yout[mask_valid] = np.nan
244+
return {"x": xout_arr, "y": yout}
245+
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)
250+
251+
res = np.empty_like(xv)
252+
res[left_mask] = yleft_val
253+
res[right_mask] = yright_val
254+
255+
if np.any(mid_mask):
256+
xm = xv[mid_mask]
257+
258+
# Find indices
259+
# j_right[k] = index of first x_reg > xm[k]
260+
j_right = np.searchsorted(x_reg, xm, side="right")
261+
# j_left[k] = index of first x_reg >= xm[k]
262+
j_left = np.searchsorted(x_reg, xm, side="left")
263+
264+
# Points that exactly match an x_reg value
265+
eq_mask = j_left != j_right
266+
# Points that fall between x_reg values
267+
in_interval_mask = ~eq_mask
268+
269+
res_mid = np.empty_like(xm)
270+
271+
if np.any(eq_mask):
272+
# For exact matches, use the corresponding y_reg value
273+
# R's C code uses the 'j_left' index here
274+
res_mid[eq_mask] = y_reg[j_left[eq_mask]]
275+
276+
if np.any(in_interval_mask):
277+
# 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
280+
281+
# 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]
286+
287+
if method_code == 1: # linear
288+
t = (xm[in_interval_mask] - xi) / (xj - xi)
289+
res_mid[in_interval_mask] = yi + (yj - yi) * t
290+
else: # constant
291+
# This matches R_approxfun's constant logic
292+
if f == 0.0:
293+
res_mid[in_interval_mask] = yi
294+
elif f == 1.0:
295+
res_mid[in_interval_mask] = yj
296+
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)
301+
if f1 != 0.0:
302+
part = part + yi * f1
303+
if f2 != 0.0:
304+
part = part + yj * f2
305+
res_mid[in_interval_mask] = part
306+
307+
res[mid_mask] = res_mid
308+
309+
yout[mask_valid] = res
310+
return {"x": xout_arr, "y": yout}

0 commit comments

Comments
 (0)