|
| 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