Skip to main content

limma/
lowess.rs

1//! Cleveland's LOWESS scatterplot smoother: a faithful port of R's `lowess()`
2//! / `clowess` (`src/library/stats/src/lowess.c`) and the `weights = NULL`
3//! classic path of limma's `loessFit` (`loessFit.R`), together with the
4//! prior-weight-aware [`weighted_lowess`] (`weightedLowess.R` +
5//! `src/weighted_lowess.c`) used by the weighted path of `loessFit`.
6//!
7//! Used by the robust *trended* variance moderation in `eBayes`
8//! (`fitFDistRobustly` with a covariate), which detrends the log-variances
9//! against the covariate with `loessFit(z, covariate, span = 0.4)`. Unlike the
10//! spline trend, a smoother has no basis-independence escape hatch: its fitted
11//! values *are* the trend, so we reproduce R's `clowess` arithmetic exactly.
12
13/// One local weighted regression of `clowess`: fit at the abscissa `xs` using
14/// the points in the 1-based inclusive window `[nleft, nright]` of the sorted
15/// data `(x, y)`. Tricube distance weights (multiplied by the robustness
16/// weights `rw` when `userw`), then a linear fit — or a locally constant one
17/// when the points are too close together to define a slope. Returns
18/// `(ys, ok)`, with `ok = false` when every weight is zero. `w` is scratch of
19/// length `n`. Mirrors the C `lowest`.
20#[allow(clippy::too_many_arguments)]
21fn lowest(
22    x: &[f64],
23    y: &[f64],
24    n: usize,
25    xs: f64,
26    nleft: usize,
27    nright: usize,
28    w: &mut [f64],
29    userw: bool,
30    rw: &[f64],
31) -> (f64, bool) {
32    let range = x[n - 1] - x[0];
33    let h = (xs - x[nleft - 1]).max(x[nright - 1] - xs);
34    let h9 = 0.999 * h;
35    let h1 = 0.001 * h;
36
37    // Sum of weights, picking up all ties on the right.
38    let mut a = 0.0;
39    let mut j = nleft; // 1-based
40    while j <= n {
41        w[j - 1] = 0.0;
42        let r = (x[j - 1] - xs).abs();
43        if r <= h9 {
44            if r <= h1 {
45                w[j - 1] = 1.0;
46            } else {
47                let t = 1.0 - (r / h).powi(3);
48                w[j - 1] = t * t * t; // tricube
49            }
50            if userw {
51                w[j - 1] *= rw[j - 1];
52            }
53            a += w[j - 1];
54        } else if x[j - 1] > xs {
55            break;
56        }
57        j += 1;
58    }
59    let nrt = j - 1; // 1-based rightmost point used (>= nright on ties)
60    if a <= 0.0 {
61        return (0.0, false);
62    }
63
64    // Normalise weights to sum to one.
65    for ww in w.iter_mut().take(nrt).skip(nleft - 1) {
66        *ww /= a;
67    }
68    if h > 0.0 {
69        // Weighted centre of the x values.
70        let mut center = 0.0;
71        for jj in nleft..=nrt {
72            center += w[jj - 1] * x[jj - 1];
73        }
74        let mut b = xs - center;
75        let mut c = 0.0;
76        for jj in nleft..=nrt {
77            let d = x[jj - 1] - center;
78            c += w[jj - 1] * d * d;
79        }
80        if c.sqrt() > 0.001 * range {
81            // Points spread out enough to use a slope.
82            b /= c;
83            for jj in nleft..=nrt {
84                w[jj - 1] *= b * (x[jj - 1] - center) + 1.0;
85            }
86        }
87    }
88    let mut ys = 0.0;
89    for jj in nleft..=nrt {
90        ys += w[jj - 1] * y[jj - 1];
91    }
92    (ys, true)
93}
94
95/// Cleveland's LOWESS: smooth `y` against ascending-sorted `x`. `f` is the
96/// span (fraction of points in each local fit), `nsteps` the number of
97/// robustness iterations, `delta` the interpolation skip distance. Returns the
98/// fitted values in the (sorted) input order. Faithful port of `clowess`.
99fn clowess(x: &[f64], y: &[f64], f: f64, nsteps: usize, delta: f64) -> Vec<f64> {
100    let n = x.len();
101    let mut ys = vec![0.0_f64; n];
102    if n < 2 {
103        if n == 1 {
104            ys[0] = y[0];
105        }
106        return ys;
107    }
108
109    // Window size: at least two, at most n points.
110    let ns = (((f * n as f64) + 1e-7) as usize).clamp(2, n);
111
112    let mut rw = vec![0.0_f64; n]; // robustness weights
113    let mut res = vec![0.0_f64; n]; // residuals
114    let mut w = vec![0.0_f64; n]; // scratch for `lowest`
115
116    let mut iter = 1usize;
117    while iter <= nsteps + 1 {
118        let mut nleft = 1usize; // 1-based window endpoints
119        let mut nright = ns;
120        let mut last = 0usize; // 1-based index of previous estimated point (0 = none)
121        let mut i = 1usize; // 1-based current point
122
123        loop {
124            if nright < n {
125                // Move the window right while that brings the far edge closer.
126                let d1 = x[i - 1] - x[nleft - 1];
127                let d2 = x[nright] - x[i - 1]; // x[nright+1] (1-based)
128                if d1 > d2 {
129                    nleft += 1;
130                    nright += 1;
131                    continue;
132                }
133            }
134
135            let (yi, ok) = lowest(x, y, n, x[i - 1], nleft, nright, &mut w, iter > 1, &rw);
136            ys[i - 1] = if ok { yi } else { y[i - 1] };
137
138            // Interpolate any points skipped by the delta jump.
139            if last < i - 1 {
140                let denom = x[i - 1] - x[last - 1];
141                for j in (last + 1)..i {
142                    let alpha = (x[j - 1] - x[last - 1]) / denom;
143                    ys[j - 1] = alpha * ys[i - 1] + (1.0 - alpha) * ys[last - 1];
144                }
145            }
146            last = i;
147
148            // Skip points within delta of the last estimated abscissa; copy the
149            // fit across exact ties.
150            let cut = x[last - 1] + delta;
151            i = last + 1;
152            while i <= n {
153                if x[i - 1] > cut {
154                    break;
155                }
156                if x[i - 1] == x[last - 1] {
157                    ys[i - 1] = ys[last - 1];
158                    last = i;
159                }
160                i += 1;
161            }
162            i = (last + 1).max(i - 1);
163            if last >= n {
164                break;
165            }
166        }
167
168        for k in 0..n {
169            res[k] = y[k] - ys[k];
170        }
171        let sc: f64 = res.iter().map(|v| v.abs()).sum::<f64>() / n as f64;
172
173        // No reweighting after the final pass.
174        if iter > nsteps {
175            break;
176        }
177        for k in 0..n {
178            rw[k] = res[k].abs();
179        }
180        // cmad = 6 * median(|res|). `rw` currently holds |res| and is fully
181        // overwritten with the bisquare weights below, so we can quickselect in
182        // place — no clone, no full O(n log n) sort. `select_nth_unstable_by`
183        // puts the m1-th order statistic at index m1 with every smaller value in
184        // `rw[..m1]`; for even n the (m1-1)-th statistic is the max of that lower
185        // partition, giving the same two central values, summed in the same
186        // order, that the full sort produced.
187        let m1 = n / 2;
188        let cmad = {
189            rw.select_nth_unstable_by(m1, |a, b| a.partial_cmp(b).unwrap());
190            if n % 2 == 0 {
191                let lo = rw[..m1].iter().copied().fold(f64::NEG_INFINITY, f64::max);
192                3.0 * (rw[m1] + lo)
193            } else {
194                6.0 * rw[m1]
195            }
196        };
197        if cmad < 1e-7 * sc {
198            break; // residuals effectively zero
199        }
200        let c9 = 0.999 * cmad;
201        let c1 = 0.001 * cmad;
202        for k in 0..n {
203            let r = res[k].abs();
204            rw[k] = if r <= c1 {
205                1.0
206            } else if r <= c9 {
207                let t = 1.0 - (r / cmad).powi(2);
208                t * t // bisquare
209            } else {
210                0.0
211            };
212        }
213        iter += 1;
214    }
215    ys
216}
217
218/// The `weights = NULL` classic-lowess path of `loessFit`, used directly by
219/// `fitFDistRobustly` (covariate detrend) and `normalizeCyclicLoess`, and by
220/// the public [`loess_fit`] wrapper when weights are absent or all-equal.
221/// Returns `(fitted, residuals)` aligned to the original order of `(y, x)`;
222/// non-finite observations are left as `NaN`, exactly as `loessFit` does.
223pub(crate) fn loess_fit_unweighted(
224    y: &[f64],
225    x: &[f64],
226    span: f64,
227    iterations: usize,
228) -> (Vec<f64>, Vec<f64>) {
229    let n = y.len();
230    let mut fitted = vec![f64::NAN; n];
231    let mut residuals = vec![f64::NAN; n];
232
233    let obs: Vec<usize> = (0..n)
234        .filter(|&k| y[k].is_finite() && x[k].is_finite())
235        .collect();
236    let nobs = obs.len();
237    if nobs == 0 {
238        return (fitted, residuals);
239    }
240    if span < 1.0 / nobs as f64 {
241        for &k in &obs {
242            fitted[k] = y[k];
243            residuals[k] = 0.0;
244        }
245        return (fitted, residuals);
246    }
247
248    // Sort the observations by x (stable, matching R's order()), smooth, then
249    // scatter the fit back to the original positions.
250    let mut order = obs.clone();
251    order.sort_by(|&a, &b| x[a].partial_cmp(&x[b]).unwrap());
252    let xs: Vec<f64> = order.iter().map(|&k| x[k]).collect();
253    let ys: Vec<f64> = order.iter().map(|&k| y[k]).collect();
254    let delta = 0.01 * (xs[nobs - 1] - xs[0]);
255
256    let fit = clowess(&xs, &ys, span, iterations.saturating_sub(1), delta);
257    for (j, &k) in order.iter().enumerate() {
258        fitted[k] = fit[j];
259        residuals[k] = y[k] - fit[j];
260    }
261    (fitted, residuals)
262}
263
264/// Seed points for `weightedLowess`: indices spaced more than `delta` apart in
265/// the ascending covariate `x`, always including the first and last point.
266/// Mirrors the C `find_seeds`.
267fn find_seeds(x: &[f64], delta: f64) -> Vec<usize> {
268    let npts = x.len();
269    let mut indices = vec![0usize];
270    let mut last_pt = 0usize;
271    for pt in 1..npts.saturating_sub(1) {
272        if x[pt] - x[last_pt] > delta {
273            indices.push(pt);
274            last_pt = pt;
275        }
276    }
277    indices.push(npts - 1);
278    indices
279}
280
281/// For each seed, expand a window left/right accumulating prior weights until
282/// the span weight is reached, always extending by the side that adds the
283/// smaller distance, then out to any ties. Returns the per-seed inclusive
284/// window `(start, end)` (0-based) and the maximum in-window distance. Mirrors
285/// the C `find_limits`.
286fn find_limits(
287    indices: &[usize],
288    x: &[f64],
289    w: &[f64],
290    spanweight: f64,
291) -> (Vec<usize>, Vec<usize>, Vec<f64>) {
292    let npts = x.len();
293    let num = indices.len();
294    let mut start = vec![0usize; num];
295    let mut end = vec![0usize; num];
296    let mut dist = vec![0.0f64; num];
297
298    for (curx, &curpt) in indices.iter().enumerate() {
299        let mut left = curpt;
300        let mut right = curpt;
301        let mut curw = w[curpt];
302        let mut at_end = curpt == npts - 1;
303        let mut at_start = curpt == 0;
304        let mut mdist = 0.0f64;
305
306        while curw < spanweight && (!at_end || !at_start) {
307            if at_end {
308                left -= 1;
309                curw += w[left];
310                if left == 0 {
311                    at_start = true;
312                }
313                let ldist = x[curpt] - x[left];
314                if mdist < ldist {
315                    mdist = ldist;
316                }
317            } else if at_start {
318                right += 1;
319                curw += w[right];
320                if right == npts - 1 {
321                    at_end = true;
322                }
323                let rdist = x[right] - x[curpt];
324                if mdist < rdist {
325                    mdist = rdist;
326                }
327            } else {
328                let ldist = x[curpt] - x[left - 1];
329                let rdist = x[right + 1] - x[curpt];
330                if ldist < rdist {
331                    left -= 1;
332                    curw += w[left];
333                    if left == 0 {
334                        at_start = true;
335                    }
336                    if mdist < ldist {
337                        mdist = ldist;
338                    }
339                } else {
340                    right += 1;
341                    curw += w[right];
342                    if right == npts - 1 {
343                        at_end = true;
344                    }
345                    if mdist < rdist {
346                        mdist = rdist;
347                    }
348                }
349            }
350        }
351
352        // Extend symmetrically across exact ties.
353        while left > 0 && x[left] == x[left - 1] {
354            left -= 1;
355        }
356        while right < npts - 1 && x[right] == x[right + 1] {
357            right += 1;
358        }
359
360        start[curx] = left;
361        end[curx] = right;
362        dist[curx] = mdist;
363    }
364    (start, end, dist)
365}
366
367/// Local weighted linear regression at `x[curpt]` over the inclusive window
368/// `[left, right]`, combining tricube distance weights with the prior weights
369/// `w` and robustness weights `rw`. Falls back to a weighted mean when the
370/// window distance or covariate variance is numerically negligible. `work` is
371/// scratch of length `npts`. Mirrors the C `lowess_fit`.
372#[allow(clippy::too_many_arguments)]
373fn weighted_local_fit(
374    x: &[f64],
375    y: &[f64],
376    w: &[f64],
377    rw: &[f64],
378    curpt: usize,
379    left: usize,
380    right: usize,
381    dist: f64,
382    work: &mut [f64],
383) -> f64 {
384    if dist < 1e-7 {
385        let mut ymean = 0.0;
386        let mut allweight = 0.0;
387        for pt in left..=right {
388            work[pt] = w[pt] * rw[pt];
389            ymean += y[pt] * work[pt];
390            allweight += work[pt];
391        }
392        return ymean / allweight;
393    }
394    let mut xmean = 0.0;
395    let mut ymean = 0.0;
396    let mut allweight = 0.0;
397    for pt in left..=right {
398        let tri = (1.0 - ((x[curpt] - x[pt]).abs() / dist).powi(3)).powi(3);
399        work[pt] = tri * w[pt] * rw[pt];
400        xmean += work[pt] * x[pt];
401        ymean += work[pt] * y[pt];
402        allweight += work[pt];
403    }
404    xmean /= allweight;
405    ymean /= allweight;
406
407    let mut var = 0.0;
408    let mut covar = 0.0;
409    for pt in left..=right {
410        let temp = x[pt] - xmean;
411        var += temp * temp * work[pt];
412        covar += temp * (y[pt] - ymean) * work[pt];
413    }
414    if var < 1e-7 {
415        return ymean;
416    }
417    let slope = covar / var;
418    let intercept = ymean - slope * xmean;
419    slope * x[curpt] + intercept
420}
421
422/// The robustness-iteration loop of the C `weighted_lowess`, operating on data
423/// already sorted by ascending `x`. Returns the fitted values and the final
424/// Tukey-biweight robustness weights, both in sorted order. The weights are
425/// recomputed from the final fit's residuals but (as in the C) not themselves
426/// applied to any fit.
427fn weighted_lowess_core(
428    x: &[f64],
429    y: &[f64],
430    w: &[f64],
431    span: f64,
432    iterations: usize,
433    delta: f64,
434) -> (Vec<f64>, Vec<f64>) {
435    let n = x.len();
436    let totalweight: f64 = w.iter().sum();
437    let spanweight = totalweight * span;
438    let subrange = (x[n - 1] - x[0]) / n as f64;
439
440    let seeds = find_seeds(x, delta);
441    let (frame_start, frame_end, max_dist) = find_limits(&seeds, x, w, spanweight);
442
443    let mut fit = vec![0.0f64; n];
444    let mut rob = vec![1.0f64; n];
445    let mut work = vec![0.0f64; n];
446    // MAD scratch reused across robustness iterations (refilled each pass).
447    let mut absres = vec![0.0f64; n];
448    let mut ror = vec![0usize; n];
449    let mut sorted = vec![0.0f64; n];
450
451    for _ in 0..iterations.max(1) {
452        // Fit at the seeds; linearly interpolate the curve between them.
453        fit[0] = weighted_local_fit(
454            x,
455            y,
456            w,
457            &rob,
458            0,
459            frame_start[0],
460            frame_end[0],
461            max_dist[0],
462            &mut work,
463        );
464        let mut last_pt = 0usize;
465        for cur_seed in 1..seeds.len() {
466            let pt = seeds[cur_seed];
467            fit[pt] = weighted_local_fit(
468                x,
469                y,
470                w,
471                &rob,
472                pt,
473                frame_start[cur_seed],
474                frame_end[cur_seed],
475                max_dist[cur_seed],
476                &mut work,
477            );
478            if pt - last_pt > 1 {
479                let current = x[pt] - x[last_pt];
480                if current > 1e-7 * subrange {
481                    let slope = (fit[pt] - fit[last_pt]) / current;
482                    let intercept = fit[pt] - slope * x[pt];
483                    for subpt in (last_pt + 1)..pt {
484                        fit[subpt] = slope * x[subpt] + intercept;
485                    }
486                } else {
487                    let endave = 0.5 * (fit[pt] + fit[last_pt]);
488                    for f in fit.iter_mut().take(pt).skip(last_pt + 1) {
489                        *f = endave;
490                    }
491                }
492            }
493            last_pt = pt;
494        }
495
496        // Weighted MAD of the absolute residuals (reusing hoisted scratch).
497        let mut resid_scale = 0.0;
498        for pt in 0..n {
499            absres[pt] = (y[pt] - fit[pt]).abs();
500            resid_scale += absres[pt];
501        }
502        resid_scale /= n as f64;
503
504        for (i, r) in ror.iter_mut().enumerate() {
505            *r = i;
506        }
507        ror.sort_by(|&a, &b| absres[a].partial_cmp(&absres[b]).unwrap());
508        for pt in 0..n {
509            sorted[pt] = absres[ror[pt]];
510        }
511
512        let halfweight = totalweight / 2.0;
513        let mut current = 0.0;
514        let mut cmad = 0.0;
515        for pt in 0..n {
516            current += w[ror[pt]];
517            if current == halfweight {
518                let next = if pt + 1 < n {
519                    sorted[pt + 1]
520                } else {
521                    sorted[pt]
522                };
523                cmad = 3.0 * (sorted[pt] + next);
524                break;
525            } else if current > halfweight {
526                cmad = 6.0 * sorted[pt];
527                break;
528            }
529        }
530
531        if cmad <= 1e-7 * resid_scale {
532            break;
533        }
534
535        for pt in 0..n {
536            rob[ror[pt]] = if sorted[pt] < cmad {
537                let t = 1.0 - (sorted[pt] / cmad).powi(2);
538                t * t
539            } else {
540                0.0
541            };
542        }
543    }
544    (fit, rob)
545}
546
547/// Fitted curve, residuals and robustness weights returned by
548/// [`weighted_lowess`], all aligned to the original input order.
549pub struct WeightedLowess {
550    pub fitted: Vec<f64>,
551    pub residuals: Vec<f64>,
552    /// Final Tukey-biweight robustness weights (the `weights` component of
553    /// limma's `weightedLowess`, *not* the input prior weights).
554    pub weights: Vec<f64>,
555    pub delta: f64,
556}
557
558/// Port of limma's `weightedLowess` (`weightedLowess.R` +
559/// `src/weighted_lowess.c`): a prior-weight-aware lowess smoother of degree 1
560/// with Tukey-biweight robustness iterations. `weights` defaults to all-ones
561/// when `None`; `delta` (the interpolation skip distance) is chosen from `npts`
562/// evenly spaced clusters when `None`, exactly as the R wrapper does. The
563/// result matches the default `output.style = "loess"`: `fitted`, `residuals`
564/// and robustness `weights` in the original (unsorted) order.
565pub fn weighted_lowess(
566    x: &[f64],
567    y: &[f64],
568    weights: Option<&[f64]>,
569    span: f64,
570    iterations: usize,
571    npts: usize,
572    delta: Option<f64>,
573) -> WeightedLowess {
574    let n = x.len();
575    assert_eq!(y.len(), n, "x and y should have same length");
576    let w_in: Vec<f64> = match weights {
577        Some(wv) => {
578            assert_eq!(wv.len(), n, "weights should have same length as x and y");
579            wv.to_vec()
580        }
581        None => vec![1.0; n],
582    };
583
584    // Order by ascending x (stable, matching R's order()).
585    let mut o: Vec<usize> = (0..n).collect();
586    o.sort_by(|&a, &b| x[a].partial_cmp(&x[b]).unwrap());
587    let xs: Vec<f64> = o.iter().map(|&k| x[k]).collect();
588    let ys: Vec<f64> = o.iter().map(|&k| y[k]).collect();
589    let ws: Vec<f64> = o.iter().map(|&k| w_in[k]).collect();
590
591    // Choose delta from the cluster structure when not supplied.
592    let delta = match delta {
593        Some(d) => d,
594        None if npts >= n => 0.0,
595        None => {
596            let mut dx: Vec<f64> = (0..n - 1).map(|i| xs[i + 1] - xs[i]).collect();
597            dx.sort_by(|a, b| a.partial_cmp(b).unwrap());
598            let mut cum = 0.0;
599            let cumrange: Vec<f64> = dx
600                .iter()
601                .map(|&d| {
602                    cum += d;
603                    cum
604                })
605                .collect();
606            let mut best = f64::INFINITY;
607            for k in 0..npts {
608                let cand = cumrange[n - 2 - k] / (npts - k) as f64;
609                if cand < best {
610                    best = cand;
611                }
612            }
613            best
614        }
615    };
616
617    let (fit_sorted, rob_sorted) = weighted_lowess_core(&xs, &ys, &ws, span, iterations, delta);
618
619    // Scatter the sorted fit back to the original order.
620    let mut fitted = vec![0.0f64; n];
621    let mut robweights = vec![0.0f64; n];
622    for (j, &k) in o.iter().enumerate() {
623        fitted[k] = fit_sorted[j];
624        robweights[k] = rob_sorted[j];
625    }
626    let residuals: Vec<f64> = (0..n).map(|i| y[i] - fitted[i]).collect();
627
628    WeightedLowess {
629        fitted,
630        residuals,
631        weights: robweights,
632        delta,
633    }
634}
635
636/// Fitted values and residuals returned by [`loess_fit`], aligned to the input
637/// order with `NaN` at non-finite observations.
638pub struct LoessFit {
639    pub fitted: Vec<f64>,
640    pub residuals: Vec<f64>,
641}
642
643/// Port of limma's `loessFit` for the default `method = "weightedLowess"`: a
644/// fast univariate lowess fit allowing prior weights. Non-finite `(y, x)` pairs
645/// are dropped and returned as `NaN`. With no weights — or weights that are all
646/// equal when `equal_weights_as_null` — it uses the classic
647/// [`loess_fit_unweighted`] path; otherwise weights are clamped to
648/// `[min_weight, max_weight]` (NaN -> 0 first) and the fit uses
649/// [`weighted_lowess`], dropping to a weighted straight-line fit when there are
650/// too few observations to support a smoother. The `method = "locfit"` and
651/// `method = "loess"` variants (which call the R packages of those names) are
652/// out of scope for this pure-Rust port.
653#[allow(clippy::too_many_arguments)]
654pub fn loess_fit(
655    y: &[f64],
656    x: &[f64],
657    weights: Option<&[f64]>,
658    span: f64,
659    iterations: usize,
660    min_weight: f64,
661    max_weight: f64,
662    equal_weights_as_null: bool,
663) -> LoessFit {
664    let n = y.len();
665    assert_eq!(x.len(), n, "y and x have different lengths");
666    let mut fitted = vec![f64::NAN; n];
667    let mut residuals = vec![f64::NAN; n];
668
669    let obs: Vec<usize> = (0..n)
670        .filter(|&k| y[k].is_finite() && x[k].is_finite())
671        .collect();
672    let nobs = obs.len();
673    if nobs == 0 {
674        return LoessFit { fitted, residuals };
675    }
676
677    // Spans below 1/nobs short-circuit to interpolation (fitted = y).
678    if span < 1.0 / nobs as f64 {
679        for &k in &obs {
680            fitted[k] = y[k];
681            residuals[k] = 0.0;
682        }
683        return LoessFit { fitted, residuals };
684    }
685
686    let min_weight = min_weight.max(0.0);
687    let xobs: Vec<f64> = obs.iter().map(|&k| x[k]).collect();
688    let yobs: Vec<f64> = obs.iter().map(|&k| y[k]).collect();
689
690    // Resolve the prior weights over the observed points; `None` means the
691    // classic unweighted path (weights absent, or all equal and treated as
692    // null).
693    let wobs: Option<Vec<f64>> = match weights {
694        None => None,
695        Some(wv) => {
696            assert_eq!(wv.len(), n, "y and weights have different lengths");
697            let wo: Vec<f64> = obs
698                .iter()
699                .map(|&k| {
700                    let w = if wv[k].is_nan() { 0.0 } else { wv[k] };
701                    w.max(min_weight).min(max_weight)
702                })
703                .collect();
704            if equal_weights_as_null {
705                let lo = wo.iter().copied().fold(f64::INFINITY, f64::min);
706                let hi = wo.iter().copied().fold(f64::NEG_INFINITY, f64::max);
707                if hi - lo < 1e-15 {
708                    None
709                } else {
710                    Some(wo)
711                }
712            } else {
713                Some(wo)
714            }
715        }
716    };
717
718    let Some(wobs) = wobs else {
719        let (f, _) = loess_fit_unweighted(&yobs, &xobs, span, iterations);
720        for (j, &k) in obs.iter().enumerate() {
721            fitted[k] = f[j];
722            residuals[k] = y[k] - f[j];
723        }
724        return LoessFit { fitted, residuals };
725    };
726
727    // Number of observations with positive weight (always positive).
728    let nwobs = if min_weight > 0.0 {
729        nobs
730    } else {
731        wobs.iter().filter(|&&w| w > 0.0).count()
732    };
733
734    // Too few observations to estimate a lowess curve: a single point, or a
735    // weighted straight line.
736    if (nwobs as f64) < 4.0 + 1.0 / span {
737        if nwobs == 1 {
738            let kpos = wobs.iter().position(|&w| w > 0.0).unwrap();
739            let val = yobs[kpos];
740            for (j, &k) in obs.iter().enumerate() {
741                fitted[k] = val;
742                residuals[k] = yobs[j] - val;
743            }
744        } else {
745            let (slope, intercept) = weighted_line_fit(&xobs, &yobs, &wobs);
746            for (j, &k) in obs.iter().enumerate() {
747                let f = intercept + slope * xobs[j];
748                fitted[k] = f;
749                residuals[k] = yobs[j] - f;
750            }
751        }
752        return LoessFit { fitted, residuals };
753    }
754
755    let wl = weighted_lowess(&xobs, &yobs, Some(&wobs), span, iterations, 200, None);
756    for (j, &k) in obs.iter().enumerate() {
757        fitted[k] = wl.fitted[j];
758        residuals[k] = y[k] - wl.fitted[j];
759    }
760    LoessFit { fitted, residuals }
761}
762
763/// Weighted least squares of `y` on `[1, x]` (the `lm.wfit(cbind(1, x), y, w)`
764/// fallback inside `loessFit`), returning `(slope, intercept)`. Closed form for
765/// simple linear regression; zero-weight rows drop out of the normal equations.
766fn weighted_line_fit(x: &[f64], y: &[f64], w: &[f64]) -> (f64, f64) {
767    let mut sw = 0.0;
768    let mut swx = 0.0;
769    let mut swy = 0.0;
770    let mut swxx = 0.0;
771    let mut swxy = 0.0;
772    for ((&xi, &yi), &wi) in x.iter().zip(y.iter()).zip(w.iter()) {
773        sw += wi;
774        swx += wi * xi;
775        swy += wi * yi;
776        swxx += wi * xi * xi;
777        swxy += wi * xi * yi;
778    }
779    let denom = sw * swxx - swx * swx;
780    let slope = (sw * swxy - swx * swy) / denom;
781    let intercept = (swy - slope * swx) / sw;
782    (slope, intercept)
783}
784
785/// R's `lowess(x, y, f, iter)`: order the points by `x`, smooth with `clowess`
786/// (`delta = 0.01 * range(x)`, as in R), and return the sorted abscissae
787/// together with the fitted ordinates — i.e. the `$x` and `$y` of `lowess`.
788/// Assumes finite inputs (the variance-trend callers guarantee this).
789fn lowess_xy(x: &[f64], y: &[f64], f: f64, iter: usize) -> (Vec<f64>, Vec<f64>) {
790    let n = x.len();
791    if n == 0 {
792        return (Vec::new(), Vec::new());
793    }
794    let mut order: Vec<usize> = (0..n).collect();
795    order.sort_by(|&a, &b| x[a].partial_cmp(&x[b]).unwrap());
796    let xs: Vec<f64> = order.iter().map(|&k| x[k]).collect();
797    let ys: Vec<f64> = order.iter().map(|&k| y[k]).collect();
798    let delta = 0.01 * (xs[n - 1] - xs[0]);
799    let fit = clowess(&xs, &ys, f, iter, delta);
800    (xs, fit)
801}
802
803/// Collapse exact-duplicate abscissae by averaging their ordinates, matching
804/// `approxfun(..., ties = list("ordered", mean))`. Input must be ascending.
805fn collapse_ties(xs: &[f64], ys: &[f64]) -> (Vec<f64>, Vec<f64>) {
806    let mut gx = Vec::new();
807    let mut gy = Vec::new();
808    let mut i = 0usize;
809    while i < xs.len() {
810        let mut j = i;
811        let mut sum = 0.0;
812        while j < xs.len() && xs[j] == xs[i] {
813            sum += ys[j];
814            j += 1;
815        }
816        gx.push(xs[i]);
817        gy.push(sum / (j - i) as f64);
818        i = j;
819    }
820    (gx, gy)
821}
822
823/// Linear interpolation over an ascending grid with `rule = 2` (values outside
824/// the grid clamp to the nearest endpoint), matching R's `approx`/`approxfun`.
825pub(crate) fn approx_rule2(gx: &[f64], gy: &[f64], xout: f64) -> f64 {
826    let n = gx.len();
827    if n == 1 {
828        return gy[0];
829    }
830    if xout <= gx[0] {
831        return gy[0];
832    }
833    if xout >= gx[n - 1] {
834        return gy[n - 1];
835    }
836    let i = gx.partition_point(|&v| v <= xout); // first index with gx > xout
837    let (x0, x1) = (gx[i - 1], gx[i]);
838    let (y0, y1) = (gy[i - 1], gy[i]);
839    y0 + (y1 - y0) * (xout - x0) / (x1 - x0)
840}
841
842/// A lowess variance trend together with its `approxfun` interpolation rule,
843/// as used by `voom`/`vooma`: fit `lowess(x, y, f = span)` then build a
844/// `rule = 2`, tie-averaged linear interpolator over the fitted curve.
845pub(crate) struct LowessInterpolator {
846    gx: Vec<f64>,
847    gy: Vec<f64>,
848}
849
850impl LowessInterpolator {
851    pub(crate) fn fit(x: &[f64], y: &[f64], span: f64, iter: usize) -> Self {
852        let (xs, ys) = lowess_xy(x, y, span, iter);
853        let (gx, gy) = collapse_ties(&xs, &ys);
854        Self { gx, gy }
855    }
856
857    pub(crate) fn eval(&self, xout: f64) -> f64 {
858        approx_rule2(&self.gx, &self.gy, xout)
859    }
860}
861
862#[cfg(test)]
863mod tests {
864    use super::*;
865
866    fn rel(a: f64, b: f64) -> f64 {
867        ((a - b) / b).abs()
868    }
869
870    /// Reference values from R 4.6.0:
871    /// `x <- 1:20; y <- sin(x/3) + (x %% 4)/10`
872    /// `lowess(x, y, f = 0.4, iter = 3L)$y`.
873    /// The classic-lowess path of `loessFit(y, x, span = 0.4)` reduces to this.
874    #[test]
875    fn lowess_matches_r_reference() {
876        let x: Vec<f64> = (1..=20).map(|i| i as f64).collect();
877        let y: Vec<f64> = (1..=20)
878            .map(|i| {
879                let xi = i as f64;
880                (xi / 3.0).sin() + ((i % 4) as f64) / 10.0
881            })
882            .collect();
883        // loessFit(y, x, span=0.4) classic path: lowess(x, y, f=0.4, iter=3).
884        let (fitted, residuals) = loess_fit_unweighted(&y, &x, 0.4, 4);
885
886        // R lowess(x, y, f=0.4, iter=3L)$y (x already ascending, so $y aligns
887        // with the input order).
888        let r_fitted = [
889            0.596_248_207_878_296,
890            0.733_833_572_576_796,
891            0.865_358_555_762_325,
892            0.994_091_762_946_332,
893            1.012_360_782_819_75,
894            0.898_791_455_642_421,
895            0.731_283_317_640_518,
896            0.529_277_971_327_881,
897            0.269_444_204_960_129,
898            -0.012_657_116_933_792_8,
899            -0.266_008_842_117_912,
900            -0.457_558_931_089_973,
901            -0.613_194_376_883_428,
902            -0.715_648_998_499_32,
903            -0.676_645_471_029_55,
904            -0.521_458_054_754_246,
905            -0.319_116_552_594_766,
906            -0.077_757_590_217_849,
907            0.171_815_169_826_499,
908            0.431_111_024_786_567,
909        ];
910        for (a, b) in fitted.iter().zip(r_fitted.iter()) {
911            assert!(rel(*a, *b) < 1e-9, "fitted {a} vs {b}");
912        }
913        // Residuals are y - fitted by construction.
914        for k in 0..x.len() {
915            assert!((residuals[k] - (y[k] - fitted[k])).abs() < 1e-15);
916        }
917    }
918
919    /// Spans below 1/n short-circuit to interpolation (fitted = y, resid = 0).
920    #[test]
921    fn lowess_tiny_span_is_identity() {
922        let x: Vec<f64> = (1..=10).map(|i| i as f64).collect();
923        let y: Vec<f64> = (1..=10).map(|i| (i * i) as f64).collect();
924        let (fitted, residuals) = loess_fit_unweighted(&y, &x, 0.05, 4);
925        for k in 0..x.len() {
926            assert_eq!(fitted[k], y[k]);
927            assert_eq!(residuals[k], 0.0);
928        }
929    }
930
931    /// Relative match, tolerating exact-zero references (zeroed robustness
932    /// weights at the planted outliers).
933    fn close(a: f64, b: f64) {
934        if b == 0.0 {
935            assert!(a.abs() < 1e-12, "{a} vs 0");
936        } else {
937            assert!(((a - b) / b).abs() < 1e-9, "{a} vs {b}");
938        }
939    }
940
941    /// Shared n=24 weighted scenario (two planted outliers) for cases A and B.
942    fn case_ab_data() -> (Vec<f64>, Vec<f64>, Vec<f64>) {
943        let x = vec![
944            0.45749338542921825,
945            0.36490716953341845,
946            2.1216858507761938,
947            2.231_517_906_338_681,
948            2.4560694953036157,
949            3.003_471_605_493_78,
950            3.2937879157555763,
951            3.619468372607308,
952            4.1084805104211775,
953            4.5866320499433755,
954            5.444_886_515_827_218,
955            5.775_470_171_559_732,
956            5.940_419_082_916_579,
957            6.149_600_128_386_905,
958            6.600_257_345_624_926,
959            6.604648996606679,
960            7.030_477_401_104_289,
961            7.093_654_185_978_542,
962            7.141587042811965,
963            7.333_683_544_946_952,
964            8.254_754_236_391_259,
965            8.694_301_976_611_142,
966            9.375_836_948_985_027,
967            9.532_155_904_709_773,
968        ];
969        let y = vec![
970            0.612_545_426_481_486_9,
971            0.650_401_760_370_632,
972            1.1542440348430094,
973            1.5486987991164907,
974            4.466_390_679_222_614,
975            1.1903665393098684,
976            0.37266251854917354,
977            0.534_045_647_135_347_6,
978            0.18834499131155025,
979            0.464_472_256_763_577_8,
980            1.0037463888693132,
981            1.1052425234922978,
982            1.3880139159873457,
983            1.4149161956436145,
984            2.061_812_038_829_897,
985            2.2424560756224436,
986            2.9043783394714633,
987            0.073_326_112_417_279_27,
988            3.0490864839234257,
989            2.8575387507063152,
990            3.430_262_298_625_616,
991            3.501_476_667_740_035,
992            3.0964170542301663,
993            2.666902407201408,
994        ];
995        let w = vec![
996            0.915_514_786_448_329_7,
997            0.45188319422304635,
998            0.34237423320300875,
999            1.1903987498488278,
1000            0.669_161_357_125_267_5,
1001            1.6571537321433425,
1002            1.1805854137521237,
1003            1.054087870148942,
1004            1.3946744401939213,
1005            0.36591726122424006,
1006            1.3710096625145525,
1007            0.863_262_355_374_172_3,
1008            0.642_421_815_311_536_2,
1009            0.737_486_680_131_405_7,
1010            1.2065324763767422,
1011            1.064640044188127,
1012            1.0586248501669615,
1013            1.8839623192790895,
1014            1.046_319_722_617_045,
1015            1.4207539540715515,
1016            1.8820373674388975,
1017            0.693_199_012_149_125_4,
1018            1.9052787743508817,
1019            0.763_943_711_994_215_8,
1020        ];
1021        (x, y, w)
1022    }
1023
1024    /// Reference: `weightedLowess(x, y, weights=w, span=0.3, iterations=4L,
1025    /// npts=200)` from limma 3.68.3 (npts>=n so delta=0; full robustness).
1026    #[test]
1027    fn weighted_lowess_case_a() {
1028        let (x, y, w) = case_ab_data();
1029        let out = weighted_lowess(&x, &y, Some(&w), 0.3, 4, 200, None);
1030        assert_eq!(out.delta, 0.0);
1031
1032        let r_fitted = [
1033            0.704_865_579_518_969_9,
1034            0.675_018_400_307_334_6,
1035            1.3600628188780086,
1036            1.4805324495189862,
1037            1.3743558087666423,
1038            1.0285760664659196,
1039            0.852_100_138_874_763_8,
1040            0.617_496_271_481_045_2,
1041            0.513_999_292_241_682_6,
1042            0.610_689_342_452_828_6,
1043            0.988_412_214_777_719_5,
1044            1.2375884034126585,
1045            1.3951903052897316,
1046            1.5905658893023542,
1047            2.152258891028989,
1048            2.1589538558884804,
1049            2.6882075718940692,
1050            2.754_372_887_365_016,
1051            2.804_782_373_798_898,
1052            2.972630547835498,
1053            3.396_904_942_104_693,
1054            3.2853367132950937,
1055            3.0552758361559125,
1056            2.996_975_009_544_398,
1057        ];
1058        let r_rweights = [
1059            0.964_573_093_916_010_6,
1060            0.9974600786593063,
1061            0.830_223_645_617_545_5,
1062            0.980_606_432_031_510_2,
1063            0.0,
1064            0.8932277961273466,
1065            0.26829417855553755,
1066            0.971_005_534_450_722_9,
1067            0.604_671_627_514_471_7,
1068            0.912_342_577_459_221_6,
1069            0.9990140555563487,
1070            0.927_887_678_710_836_4,
1071            0.999_784_013_165_360_9,
1072            0.874_786_293_731_308_1,
1073            0.965_983_909_872_752_1,
1074            0.970_969_934_423_463_4,
1075            0.813_613_521_418_203_6,
1076            0.0,
1077            0.765_342_631_054_660_8,
1078            0.945_216_049_382_716,
1079            0.995_338_614_565_773_8,
1080            0.813_663_916_297_942_5,
1081            0.992_913_666_264_513_6,
1082            0.595_259_735_008_007_2,
1083        ];
1084        for (a, b) in out.fitted.iter().zip(r_fitted.iter()) {
1085            close(*a, *b);
1086        }
1087        for (a, b) in out.weights.iter().zip(r_rweights.iter()) {
1088            close(*a, *b);
1089        }
1090        for ((r, yy), f) in out.residuals.iter().zip(y.iter()).zip(out.fitted.iter()) {
1091            assert!((r - (yy - f)).abs() < 1e-15);
1092        }
1093    }
1094
1095    /// Reference: same data, `span=0.5, iterations=1L` — a non-robust base fit
1096    /// whose returned robustness weights are computed but never applied.
1097    #[test]
1098    fn weighted_lowess_case_b() {
1099        let (x, y, w) = case_ab_data();
1100        let out = weighted_lowess(&x, &y, Some(&w), 0.5, 1, 200, None);
1101        assert_eq!(out.delta, 0.0);
1102
1103        let r_fitted = [
1104            1.3164091907640776,
1105            1.3175754346917248,
1106            1.2097472947654593,
1107            1.2023153256037815,
1108            1.1902171010985594,
1109            1.1769344746702706,
1110            1.1383757235477594,
1111            1.004_305_077_879_562,
1112            0.796_818_060_092_517,
1113            0.774_791_789_454_579_5,
1114            1.031_357_331_214_907,
1115            1.2516144937670477,
1116            1.358_007_678_833_491,
1117            1.4738918429020829,
1118            1.7425837851859414,
1119            1.7453266670230196,
1120            2.0349113652577646,
1121            2.099_264_238_862_616,
1122            2.148618243528349,
1123            2.340_243_909_841_483,
1124            3.010_161_361_558_707,
1125            3.056683040538843,
1126            3.158_235_211_076_587,
1127            3.1633262263048074,
1128        ];
1129        let r_rweights = [
1130            0.879_412_603_471_390_4,
1131            0.8913033071647305,
1132            0.999_226_241_529_259_7,
1133            0.9700854954413779,
1134            0.0,
1135            0.999_954_675_492_639_4,
1136            0.858_129_979_744_656_7,
1137            0.945_216_049_382_716,
1138            0.909_151_582_734_925_9,
1139            0.975_954_372_722_726_4,
1140            0.999_808_488_820_543_1,
1141            0.994_624_937_295_328_6,
1142            0.999_773_821_675_273_3,
1143            0.999_126_419_320_665,
1144            0.974_562_958_604_831_9,
1145            0.9388779867911321,
1146            0.819_102_172_057_449_2,
1147            0.2346871608581525,
1148            0.806_674_164_798_635_2,
1149            0.933_905_106_727_050_2,
1150            0.956_155_094_856_453_8,
1151            0.950_916_050_426_533_5,
1152            0.999_040_200_929_231_7,
1153            0.939_048_642_187_021_6,
1154        ];
1155        for (a, b) in out.fitted.iter().zip(r_fitted.iter()) {
1156            close(*a, *b);
1157        }
1158        for (a, b) in out.weights.iter().zip(r_rweights.iter()) {
1159            close(*a, *b);
1160        }
1161    }
1162
1163    /// Reference: n=80, `npts=30` so the wrapper picks delta>0 and the C path
1164    /// samples seeds and interpolates between them. `span=0.3, iterations=4L`.
1165    #[test]
1166    fn weighted_lowess_case_c() {
1167        let x = [
1168            0.531_976_991_333_067_4,
1169            0.683_757_108_636_200_4,
1170            0.789_968_837_052_583_7,
1171            0.801_603_901_199_996_5,
1172            1.5255942661315203,
1173            1.581659372895956,
1174            2.577_251_400_798_559,
1175            2.7313429210335016,
1176            3.1198115088045597,
1177            3.2957231905311346,
1178            3.5041399160400033,
1179            3.535_446_380_265_057,
1180            3.562_620_780_430_734,
1181            3.573_531_210_422_516,
1182            3.904986004345119,
1183            3.969_677_845_016_122,
1184            4.026_793_888_770_044,
1185            4.0656226221472025,
1186            4.074_543_830_938_637,
1187            4.320_158_297_196_031,
1188            4.373_944_881_372_154,
1189            4.3781809182837605,
1190            4.381_559_635_512_531,
1191            4.541_048_249_229_789,
1192            4.595673899166286,
1193            5.085_642_351_768_911,
1194            5.320_935_468_189_418,
1195            5.745_922_313_071_787,
1196            5.820684814825654,
1197            6.121_302_433_311_939,
1198            6.595_797_222_107_649,
1199            7.196_558_876_894_414,
1200            7.365_691_293_962_3,
1201            7.962_349_629_960_954,
1202            8.258_966_878_056_526,
1203            8.290_081_201_121_211,
1204            8.838_997_278_362_513,
1205            8.898_926_260_881_126,
1206            9.147_843_788_377_94,
1207            9.250_376_787_967_98,
1208            9.582185884937644,
1209            10.625_021_490_268_41,
1210            10.671597444452345,
1211            11.63403379265219,
1212            11.695629926398396,
1213            11.993930744938552,
1214            12.457542419433594,
1215            12.604068955406547,
1216            12.657269365154207,
1217            12.804370601661503,
1218            12.825214397162199,
1219            12.832566713914275,
1220            12.845537355169654,
1221            13.152610226534307,
1222            13.243088563904166,
1223            13.403024673461914,
1224            13.606816763058305,
1225            13.800439438782632,
1226            13.916632984764874,
1227            14.362269355915487,
1228            14.631125540472567,
1229            15.467019253410399,
1230            15.646071173250675,
1231            16.216_562_888_585_03,
1232            16.22252113185823,
1233            16.337_592_676_281_93,
1234            16.472833515144885,
1235            16.546852341853082,
1236            16.616179938428104,
1237            16.9334597280249,
1238            17.528_903_256_170_45,
1239            17.619_530_349_038_54,
1240            17.659138469025493,
1241            17.849509133957326,
1242            17.9549042833969,
1243            18.515_662_457_793_95,
1244            19.050154094584286,
1245            19.228_869_844_228_03,
1246            19.516776781529188,
1247            19.939716095104814,
1248        ];
1249        let y = [
1250            1.293428527408967,
1251            1.1000429951508104,
1252            0.985_676_209_295_923_4,
1253            1.078416645203697,
1254            1.0384497202430822,
1255            0.911_168_961_849_530_4,
1256            0.814_110_599_798_517_6,
1257            0.30870061707136665,
1258            0.360_630_501_091_990_5,
1259            2.653220107028643,
1260            0.035616391685330595,
1261            0.3396670541930909,
1262            0.1502442458597838,
1263            0.47763647905409656,
1264            0.094_209_347_547_079_92,
1265            0.10090502868850283,
1266            -0.012099541381990271,
1267            -0.018_456_884_031_812_94,
1268            0.027497693822005634,
1269            -0.139_844_621_530_572_6,
1270            0.012543821944120875,
1271            -0.003_995_997_315_695_388,
1272            -0.130_293_614_459_443_5,
1273            -0.46252856466785347,
1274            -0.19381646487771756,
1275            -0.35165581858660344,
1276            -0.320_150_797_023_739_7,
1277            -0.5361856364472154,
1278            -0.45587942674406273,
1279            -0.24861363699527775,
1280            -0.292_622_685_302_052_5,
1281            -0.28060451696229294,
1282            -0.11620781461766616,
1283            0.11487078623846304,
1284            0.26052335888772676,
1285            0.20269409789109122,
1286            0.431_968_733_064_165_2,
1287            0.276_224_130_937_391_6,
1288            0.47591651163197063,
1289            0.823_716_212_214_928_2,
1290            1.0238334201209505,
1291            1.7712082466756467,
1292            1.6076809524574995,
1293            2.032_190_109_082_845,
1294            1.7614737823535958,
1295            2.3284019188313416,
1296            2.4427408893291362,
1297            2.2803873792713096,
1298            2.3519177007491914,
1299            2.193_670_558_739_165,
1300            2.1263994596978226,
1301            2.3645007168762517,
1302            2.5249861145768056,
1303            2.122_272_855_834_001,
1304            0.299_203_443_460_228,
1305            2.172805960509645,
1306            2.3805956106200714,
1307            2.2635884407253357,
1308            2.1413513061524423,
1309            2.2424209481860458,
1310            1.8287138511852497,
1311            1.661_710_250_360_172,
1312            1.456_810_520_104_257,
1313            1.3638716209786697,
1314            1.3174847840211241,
1315            1.1294361324150146,
1316            1.6406879454416847,
1317            1.227_972_499_725_56,
1318            1.333044215401372,
1319            1.0429699942281332,
1320            0.830_352_777_457_079_7,
1321            0.594_504_552_950_756,
1322            0.864_889_768_902_684_3,
1323            1.0988525623839671,
1324            1.122_004_167_081_219,
1325            0.837_027_039_099_292_9,
1326            0.840_324_931_775_807_9,
1327            1.0662167330324928,
1328            0.7984426688798818,
1329            0.886_983_471_386_884_5,
1330        ];
1331        let w = [
1332            1.0889637747313827,
1333            1.4202703633345664,
1334            1.275_927_386_712_283,
1335            0.988_120_193_593_204,
1336            0.700_306_445_360_183_7,
1337            0.700_581_608_805_805_4,
1338            0.591_909_030_452_370_6,
1339            0.729152005398646,
1340            0.791_078_662_732_616_1,
1341            1.0620378700550646,
1342            1.2971524118911475,
1343            1.3690105613786727,
1344            0.929_895_969_340_577_7,
1345            0.623_054_652_707_651_3,
1346            1.2314715515822172,
1347            1.3028102195821702,
1348            1.3870370583608747,
1349            1.3259274356532842,
1350            1.3139970258343965,
1351            0.940_836_715_046_316_4,
1352            0.608_983_992_366_120_2,
1353            1.3987310056108981,
1354            0.797_507_378_039_881_6,
1355            0.788_213_320_076_465_6,
1356            0.765_858_371_742_069_7,
1357            0.582_622_988_615_185,
1358            0.907_211_071_578_785_8,
1359            1.3830148903653026,
1360            1.1364444366190583,
1361            1.4955437039025128,
1362            1.3725970827508718,
1363            1.314765295246616,
1364            1.106_141_550_699_249,
1365            0.869_393_355_911_597_6,
1366            0.928_705_062_018_707_4,
1367            0.695_141_761_098_057,
1368            0.962_294_715_689_495_2,
1369            0.610_878_398_176_282_6,
1370            1.4070586825255305,
1371            0.691_825_973_335_653_5,
1372            1.4032431468367577,
1373            1.4545864365063608,
1374            0.595_691_040_623_933_1,
1375            0.695_055_700_605_735_2,
1376            1.334_562_980_569_899,
1377            1.337_477_844_208_479,
1378            0.969_861_233_141_273_3,
1379            0.6534878071397543,
1380            1.439_056_930_365_041,
1381            1.483_104_110_462_591,
1382            1.316_310_929_134_488,
1383            0.9697276595979929,
1384            0.913_404_341_088_607_9,
1385            1.1443348934408277,
1386            0.999_848_530_394_956_5,
1387            1.0565558585803956,
1388            0.955_150_888_534_262_8,
1389            0.8411982893012464,
1390            1.2106719859875739,
1391            1.0034676706418395,
1392            0.781_219_038_413_837_6,
1393            0.896_646_452_136_337_8,
1394            1.325254688039422,
1395            0.828_120_636_753_737_9,
1396            1.2752281511202455,
1397            1.378995991544798,
1398            1.4765544817782938,
1399            0.808_399_976_463_988_4,
1400            1.0114127546548843,
1401            0.702_006_856_212_392_4,
1402            1.4021367505192757,
1403            1.4634487400762737,
1404            0.856_994_667_090_475_6,
1405            0.596_906_685_270_369,
1406            1.4358097377698869,
1407            1.249910962767899,
1408            1.3429545727558434,
1409            1.313_349_277_479_574,
1410            0.515_121_111_180_633_3,
1411            0.502_998_038_660_734_9,
1412        ];
1413        let out = weighted_lowess(&x, &y, Some(&w), 0.3, 4, 30, None);
1414        assert!(
1415            rel(out.delta, 0.593_369_432_979_009_3) < 1e-12,
1416            "delta {}",
1417            out.delta
1418        );
1419
1420        let r_fitted = [
1421            1.1884821422349683,
1422            1.1401746805937334,
1423            1.1063703912870086,
1424            1.1026672684736833,
1425            0.872_240_929_345_854_8,
1426            0.855_951_874_762_393_3,
1427            0.5666943700277064,
1428            0.510_552_240_462_768_4,
1429            0.369_016_523_550_517_2,
1430            0.304_924_376_153_817_2,
1431            0.227_306_278_971_063_6,
1432            0.215_647_194_621_015_9,
1433            0.20552696445334262,
1434            0.20146372618232533,
1435            0.078_024_062_961_633_33,
1436            0.057_933_325_377_075_74,
1437            0.040_195_331_914_130_16,
1438            0.028_136_656_740_725_74,
1439            0.025_366_080_684_971_15,
1440            -0.050_912_098_098_715,
1441            -0.067_616_092_559_898_98,
1442            -0.068_931_638_772_633_97,
1443            -0.069_980_935_260_592_24,
1444            -0.11951181663114796,
1445            -0.1295277526928138,
1446            -0.21936636695564127,
1447            -0.262_508_751_761_269_3,
1448            -0.261_180_486_021_105_6,
1449            -0.260_946_821_212_595_9,
1450            -0.26000726252193695,
1451            -0.167_592_176_158_451_8,
1452            -0.050_584_694_681_363_06,
1453            0.00809634544820792,
1454            0.21510888843295684,
1455            0.33873746515295533,
1456            0.351_705_758_469_647,
1457            0.580_491_217_858_042_1,
1458            0.6082580657896921,
1459            0.723_588_826_350_821_9,
1460            0.771_095_359_716_515_1,
1461            0.924_832_205_407_716,
1462            1.4089433518016659,
1463            1.4327444779344392,
1464            1.9245662627937583,
1465            1.940_370_302_694_94,
1466            2.0169068927703515,
1467            2.135_858_147_609_854,
1468            2.1450068314468513,
1469            2.1483285077940617,
1470            2.157_513_074_233_857,
1471            2.1588144992196634,
1472            2.159_273_556_147_39,
1473            2.160083404651763,
1474            2.1792561271649924,
1475            2.1677064401616297,
1476            2.1472903708612345,
1477            2.1212760240350104,
1478            2.0965598171906232,
1479            2.065_629_550_176_702,
1480            1.9470028929533285,
1481            1.875434429262878,
1482            1.6244677094067717,
1483            1.5664639693873488,
1484            1.3816535905374199,
1485            1.3801654339673561,
1486            1.3514246681057358,
1487            1.3176463321656406,
1488            1.299_159_070_363_2,
1489            1.2818435104574863,
1490            1.2025983400628886,
1491            1.1018567306289988,
1492            1.0873066282823167,
1493            1.0809475784515046,
1494            1.0503837308105375,
1495            1.0334626295961167,
1496            0.943_433_383_693_995,
1497            0.861_571_735_904_992_1,
1498            0.834_199_996_583_610_7,
1499            0.792_602_471_709_358_3,
1500            0.731_495_126_462_035_3,
1501        ];
1502        for (a, b) in out.fitted.iter().zip(r_fitted.iter()) {
1503            close(*a, *b);
1504        }
1505        let sum: f64 = out.fitted.iter().sum();
1506        assert!(rel(sum, 73.941_169_255_579_42) < 1e-9, "sum {sum}");
1507    }
1508
1509    /// Reference: n=15 with **unsorted** x — exercises the wrapper's internal
1510    /// ordering and the unsort of fitted/residuals back to input order.
1511    /// `span=0.4, iterations=3L`.
1512    #[test]
1513    fn weighted_lowess_case_d_unsorted() {
1514        let x = [
1515            2.4769279826432467,
1516            6.676_589_386_537_671,
1517            4.878_524_420_782_924,
1518            5.919_815_640_896_559,
1519            5.197_473_073_378_205,
1520            7.844762085005641,
1521            4.218_711_268_156_767,
1522            3.7431930266320705,
1523            2.252_489_222_213_626,
1524            2.2300906758755445,
1525            7.2444714065641165,
1526            6.980_926_280_841_231,
1527            0.829_540_582_373_738_3,
1528            0.291_089_225_560_426_7,
1529            1.8692466896027327,
1530        ];
1531        let y = [
1532            3.1588662152772407,
1533            5.265_610_750_585_987,
1534            4.479_077_038_757_614,
1535            4.999_896_114_465_269,
1536            4.528_259_545_777_066,
1537            5.9505529060869105,
1538            3.9898370326344743,
1539            3.837_755_420_730_662,
1540            2.957781766496248,
1541            3.0561989716819555,
1542            5.562_200_231_289_714,
1543            5.482_773_635_299_048,
1544            2.5100552062553017,
1545            2.106_936_485_716_76,
1546            2.938_948_707_868_278,
1547        ];
1548        let w = [
1549            1.3902532287407665,
1550            0.648_387_671_448_290_3,
1551            0.560_372_849_553_823_4,
1552            1.3949049064423888,
1553            0.839_275_473_868_474_2,
1554            1.0821476810146122,
1555            1.1763596059288828,
1556            0.8876171682029963,
1557            1.2749323339201508,
1558            0.48667634087614714,
1559            0.887_075_331_481_173_7,
1560            1.6853833251167087,
1561            1.1941875480115414,
1562            1.34794178516604,
1563            1.1733048296067863,
1564        ];
1565        let out = weighted_lowess(&x, &y, Some(&w), 0.4, 3, 200, None);
1566        assert_eq!(out.delta, 0.0);
1567
1568        let r_fitted = [
1569            3.1610682329090687,
1570            5.328_131_047_276_528,
1571            4.401525118617398,
1572            4.953068229283593,
1573            4.574_573_132_447_472,
1574            5.921_881_766_698_609,
1575            4.045_695_517_076_606,
1576            3.8024012703068983,
1577            3.060_630_080_369_762,
1578            3.0500104995362616,
1579            5.6113672538311326,
1580            5.477_972_596_741_132,
1581            2.4294025367732424,
1582            2.142_734_808_562_499,
1583            2.907_051_913_114_72,
1584        ];
1585        let r_residuals = [
1586            -0.002_202_017_631_828_035,
1587            -0.062_520_296_690_541_6,
1588            0.077_551_920_140_216_17,
1589            0.04682788518167591,
1590            -0.046_313_586_670_406_08,
1591            0.028671139388301015,
1592            -0.055858484442131484,
1593            0.035_354_150_423_763_55,
1594            -0.10284831387351412,
1595            0.006_188_472_145_693_957,
1596            -0.049_167_022_541_418_25,
1597            0.004_801_038_557_915_582,
1598            0.080_652_669_482_059_29,
1599            -0.03579832284573925,
1600            0.031_896_794_753_558_22,
1601        ];
1602        for (a, b) in out.fitted.iter().zip(r_fitted.iter()) {
1603            close(*a, *b);
1604        }
1605        for (a, b) in out.residuals.iter().zip(r_residuals.iter()) {
1606            assert!((a - b).abs() < 1e-9, "{a} vs {b}");
1607        }
1608    }
1609
1610    // ---- loessFit (the weightedLowess consumer) ----------------------------
1611    // Reference cases L1-L5 from `scratch/loessfit_ref.R` (limma 3.68.3, R
1612    // 4.6.0). The wrapper is called with R's defaults: min.weight=1e-5,
1613    // max.weight=1e5, equal.weights.as.null=TRUE.
1614
1615    /// Compare against an R reference vector, treating `NaN` as the dropped
1616    /// non-finite observations limma returns as `NA`.
1617    fn close_vec(got: &[f64], want: &[f64]) {
1618        assert_eq!(got.len(), want.len(), "length mismatch");
1619        for (a, b) in got.iter().zip(want.iter()) {
1620            if b.is_nan() {
1621                assert!(a.is_nan(), "expected NA, got {a}");
1622            } else {
1623                close(*a, *b);
1624            }
1625        }
1626    }
1627
1628    /// Residuals equal `y - fitted` in every loessFit path; verify that
1629    /// structurally (NaN-aware) rather than via a relative tolerance, which
1630    /// would amplify rounding on the near-zero residuals.
1631    fn check_residuals_are_y_minus_fitted(out: &LoessFit, y: &[f64]) {
1632        for ((r, yy), f) in out.residuals.iter().zip(y.iter()).zip(out.fitted.iter()) {
1633            let expected = yy - f;
1634            if expected.is_nan() {
1635                assert!(r.is_nan(), "expected NA residual, got {r}");
1636            } else {
1637                assert!((r - expected).abs() < 1e-12, "{r} vs {expected}");
1638            }
1639        }
1640    }
1641
1642    /// L1: weighted (n=40, span=0.3, iter=4) with one non-finite covariate
1643    /// (x[14]) and one non-finite response (y[27]); a planted outlier at
1644    /// index 6 keeps its full residual. Exercises the weightedLowess path.
1645    #[test]
1646    fn loess_fit_l1_weighted_with_na() {
1647        let nan = f64::NAN;
1648        let x = [
1649            0.005_183_129_105_716_944,
1650            0.14047908363863826,
1651            0.16214577946811914,
1652            0.578_589_488_286_525,
1653            0.619_884_438_347_071_4,
1654            0.646_897_766_273_468_7,
1655            0.864_958_912_134_170_5,
1656            1.0355600714683533,
1657            1.2321620131842792,
1658            1.250_832_553_487_271,
1659            1.576_602_489_221_841,
1660            1.7511292267590761,
1661            2.024_130_343_925_208,
1662            2.0371259469538927,
1663            nan,
1664            2.7687330706976354,
1665            2.7724979422055185,
1666            2.899_750_091_601_163,
1667            3.276793584227562,
1668            3.3061099448241293,
1669            3.5034838342107832,
1670            3.637_880_280_148_238,
1671            3.981_494_423_933_327,
1672            3.991_432_855_837_047,
1673            4.117_796_053_178_608,
1674            4.407_502_717_804_164,
1675            4.801_340_533_886_105,
1676            4.817_655_358_929_187,
1677            4.830_177_505_500_615,
1678            5.0491762650199234,
1679            5.1060837297700346,
1680            5.7368572149425745,
1681            6.420_319_871_976_972,
1682            6.8036738759838045,
1683            7.339_874_927_420_169,
1684            8.126_682_038_418_949,
1685            8.510_418_669_320_643,
1686            8.806_991_728_488_356,
1687            9.071_829_735_767_096,
1688            9.548_492_254_689_336,
1689        ];
1690        let y = [
1691            -0.13028379272342372,
1692            0.16494167307661872,
1693            0.10534440906646064,
1694            0.7330729492059922,
1695            0.719_552_107_645_758_6,
1696            0.7335251729085972,
1697            3.896_540_372_057_267,
1698            0.9141200377945875,
1699            1.1454302634920055,
1700            1.0026956063690007,
1701            1.0944468337004631,
1702            1.1463798418508317,
1703            1.4395428355871485,
1704            0.985_150_160_028_773_8,
1705            0.887_350_237_670_555_2,
1706            1.0149620403673598,
1707            0.878_060_307_279_870_1,
1708            1.1285529306977484,
1709            0.398_293_292_094_334_6,
1710            0.42789453241135494,
1711            0.019349953543560616,
1712            0.255_488_015_807_366_5,
1713            0.230_069_869_085_910_2,
1714            -0.12743829720812616,
1715            0.17319277852114137,
1716            -0.14115542663394637,
1717            -0.473_134_585_337_793_2,
1718            nan,
1719            0.11773586387606913,
1720            0.10998459160176483,
1721            0.255_692_811_653_590_8,
1722            0.581_819_342_086_163_4,
1723            1.2570686237489412,
1724            1.9579856745571356,
1725            2.370_545_808_258_101,
1726            2.6969087938082077,
1727            2.462_926_449_216_594,
1728            2.4283890102220855,
1729            2.457_605_861_523_145,
1730            1.7983325143797855,
1731        ];
1732        let w = [
1733            0.636_493_015_708_401_8,
1734            0.791_828_367_765_992_9,
1735            1.9835953457048163,
1736            0.589_909_437_927_417_5,
1737            1.0680007479852065,
1738            0.42237004640046505,
1739            0.34347869313787666,
1740            1.7559089590795338,
1741            1.4984269458800554,
1742            1.372_503_425_157_629,
1743            1.9231961047044024,
1744            1.4591313774231822,
1745            0.664_618_161_669_932_3,
1746            1.1917643264634534,
1747            0.5399193662917241,
1748            1.1196884553181008,
1749            0.761_779_755_516_909,
1750            1.5054568398045376,
1751            1.6169831238687038,
1752            1.988671691226773,
1753            1.4473249244736508,
1754            1.2495072918944061,
1755            1.0664991330821068,
1756            1.4697452861350029,
1757            0.678_594_810_375_943_8,
1758            1.1991494379937648,
1759            1.3034791655139997,
1760            0.862_886_963_831_260_8,
1761            1.7764983414905147,
1762            0.817_129_175_551_235_5,
1763            1.2557559457607568,
1764            1.599_651_281_326_078,
1765            0.717_321_668_099_612,
1766            1.6017108282074333,
1767            1.4470027274917812,
1768            1.3925923728384078,
1769            0.542_866_303_306_073,
1770            0.613_774_243_392_981_6,
1771            1.4151965341996402,
1772            1.2633167420513927,
1773        ];
1774        let out = loess_fit(&y, &x, Some(&w), 0.3, 4, 1e-5, 1e5, true);
1775        let r_fitted = [
1776            0.0020154160943804374,
1777            0.131_892_620_268_470_8,
1778            0.15244602671480564,
1779            0.537_033_400_554_882_2,
1780            0.5749773054397006,
1781            0.599_992_286_147_306_4,
1782            0.820_962_205_024_607_4,
1783            0.922_188_649_092_287,
1784            0.999_131_539_751_303_6,
1785            1.0042360193467927,
1786            1.084424219541861,
1787            1.105226618879442,
1788            1.0789358441377175,
1789            1.0790114350851858,
1790            nan,
1791            0.858_709_557_143_218_8,
1792            0.856_107_782_666_883_5,
1793            0.8347718825091448,
1794            0.505_231_802_313_234_7,
1795            0.47837184475249783,
1796            0.295_909_335_228_325_1,
1797            0.20354399729824024,
1798            0.054_388_087_732_097_47,
1799            0.050_612_262_098_252_09,
1800            0.016304620106176948,
1801            0.041_451_857_739_025_66,
1802            0.10396584158117128,
1803            nan,
1804            0.1157231185509553,
1805            0.20740861707140956,
1806            0.23703826228746294,
1807            0.760_303_516_697_105_9,
1808            1.419_606_559_087_084,
1809            1.7266927821338722,
1810            2.0077981459480405,
1811            2.3279293123460407,
1812            2.475_834_436_511_091,
1813            2.5825278346690603,
1814            2.671_725_980_932_141,
1815            2.819_077_988_722_33,
1816        ];
1817        close_vec(&out.fitted, &r_fitted);
1818        check_residuals_are_y_minus_fitted(&out, &y);
1819        // The outlier at index 6 keeps its full residual.
1820        close(out.residuals[6], 3.075_578_167_032_66);
1821    }
1822
1823    /// L2: too few obs for a smoother (n=6 < 4 + 1/0.3), so loessFit drops to a
1824    /// weighted straight-line fit. Exercises `weighted_line_fit`.
1825    #[test]
1826    fn loess_fit_l2_wls_branch() {
1827        let x = [
1828            1.5213841770309955,
1829            2.3736945423297584,
1830            2.6032693102024496,
1831            3.6165727430488914,
1832            4.216_155_161_848_292,
1833            4.967628859449178,
1834        ];
1835        let y = [
1836            2.1528132956168466,
1837            2.598_898_371_290_544,
1838            3.3797162340845226,
1839            3.511_792_998_604_96,
1840            3.9024791277875033,
1841            4.417381997642738,
1842        ];
1843        let w = [
1844            1.265_375_897_428_021,
1845            0.920_177_808_171_138_1,
1846            0.711_445_754_114_538_5,
1847            0.904_493_984_859_436_8,
1848            1.1457260956522077,
1849            1.515057343104854,
1850        ];
1851        let out = loess_fit(&y, &x, Some(&w), 0.3, 4, 1e-5, 1e5, true);
1852        let r_fitted = [
1853            2.221282436297829,
1854            2.765_360_343_792_229,
1855            2.911_910_901_433_168,
1856            3.558_759_788_700_577,
1857            3.941_507_155_238_185,
1858            4.421215315052069,
1859        ];
1860        close_vec(&out.fitted, &r_fitted);
1861        check_residuals_are_y_minus_fitted(&out, &y);
1862    }
1863
1864    /// L3: all-equal weights collapse to NULL, so loessFit takes the classic
1865    /// `lowess` path. n=20, span=0.4, iter=4.
1866    #[test]
1867    fn loess_fit_l3_equal_weights_classic() {
1868        let x = [
1869            0.12413568049669266,
1870            0.943_929_295_986_890_8,
1871            1.0861205589026213,
1872            1.8004096411168575,
1873            2.0838854778558016,
1874            2.739089785143733,
1875            2.7455857265740633,
1876            3.097_543_876_618_147,
1877            3.157_202_487_811_446,
1878            3.4970000348985195,
1879            3.5675238333642483,
1880            3.869_830_997_660_756,
1881            4.138_796_932_995_319,
1882            5.527_887_191_623_449,
1883            6.197_990_983_724_594,
1884            6.255_103_558_301_926,
1885            6.7459734827280045,
1886            6.751_051_519_066_095,
1887            7.202_860_610_559_583,
1888            7.351_007_658_988_237,
1889        ];
1890        let y = [
1891            1.0298211176564178,
1892            0.676_610_752_805_095_6,
1893            0.855_910_305_007_615_9,
1894            -0.010864869491520768,
1895            -0.17715935228426227,
1896            -0.6484373184623734,
1897            -0.6695555621419208,
1898            -0.6411756350457184,
1899            -0.665_807_808_281_218_5,
1900            -0.677_004_584_491_505_2,
1901            -0.6202612231020217,
1902            -0.315_789_402_713_021_6,
1903            -0.020225008839295677,
1904            1.349_792_027_254_391,
1905            1.6439380305192044,
1906            1.6601868401446462,
1907            1.6584092984580048,
1908            1.8678128119816537,
1909            1.0507848512764517,
1910            1.087775447878236,
1911        ];
1912        let w = [2.0; 20];
1913        let out = loess_fit(&y, &x, Some(&w), 0.4, 4, 1e-5, 1e5, true);
1914        let r_fitted = [
1915            1.131_683_235_813_225,
1916            0.622_701_981_921_744,
1917            0.5340726402959507,
1918            0.0534601137616516,
1919            -0.19037724883930937,
1920            -0.581_662_324_318_019_5,
1921            -0.583_783_621_386_261_8,
1922            -0.646_813_245_039_454_5,
1923            -0.640_248_250_804_955_1,
1924            -0.569_616_038_400_150_6,
1925            -0.517_887_741_556_381_1,
1926            -0.30168620597235857,
1927            -0.094_559_894_415_800_07,
1928            1.262_038_052_285_814,
1929            1.4901527265313739,
1930            1.4785335054830242,
1931            1.3616342992329014,
1932            1.360_321_175_900_416,
1933            1.2346611633429285,
1934            1.1890588683115497,
1935        ];
1936        close_vec(&out.fitted, &r_fitted);
1937        check_residuals_are_y_minus_fitted(&out, &y);
1938    }
1939
1940    /// L4: weights with one NA and two out-of-range values that get clamped to
1941    /// [1e-5, 1e5]; the 1e5-weighted point pins the curve to its y (zero
1942    /// residual). n=12, span=0.5, iter=3. Weighted path.
1943    #[test]
1944    fn loess_fit_l4_clamped_weights() {
1945        let nan = f64::NAN;
1946        let x = [
1947            0.000_812_362_879_514_694_2,
1948            0.19315525190904737,
1949            0.341_314_423_363_655_8,
1950            0.550_810_409_244_149_9,
1951            0.691_795_941_442_251_2,
1952            0.860_569_709_446_281_2,
1953            1.6972281779162586,
1954            2.683_141_722_343_862,
1955            2.6952867256477475,
1956            3.0456017875112593,
1957            3.4599765236489475,
1958            4.460_506_583_098_322,
1959        ];
1960        let y = [
1961            0.18370482724257098,
1962            0.063_986_626_702_510_85,
1963            -0.14976651732047797,
1964            0.1083635469696469,
1965            0.418_286_167_062_277_7,
1966            0.365_527_766_084_957_4,
1967            0.7026405120499789,
1968            1.2006499392960333,
1969            1.2752066988779682,
1970            1.5496202606111031,
1971            2.0578605625298567,
1972            1.9503358137564917,
1973        ];
1974        let w = [
1975            1.4987861497793347,
1976            0.835_285_151_377_320_3,
1977            nan,
1978            0.979_024_216_765_537_9,
1979            1.0222810602281243,
1980            0.813_572_955_550_625_9,
1981            1.1607185169123113,
1982            1e-08,
1983            0.676_338_533_638_045_2,
1984            10000000.0,
1985            0.755_478_185_601_532_5,
1986            0.796_876_986_976_712_9,
1987        ];
1988        let out = loess_fit(&y, &x, Some(&w), 0.5, 3, 1e-5, 1e5, true);
1989        let r_fitted = [
1990            0.092_715_644_709_798_5,
1991            0.1571141562155525,
1992            0.20738759663288137,
1993            0.27930367717161875,
1994            0.328_256_797_435_878_9,
1995            0.38783005712437973,
1996            0.730_831_473_478_615_7,
1997            1.2752055964112556,
1998            1.2752055966734654,
1999            1.5496202606111031,
2000            2.0578605625298567,
2001            1.9503358137564917,
2002        ];
2003        close_vec(&out.fitted, &r_fitted);
2004        check_residuals_are_y_minus_fitted(&out, &y);
2005        // The 1e5-weighted point (index 9) is fitted exactly: zero residual.
2006        close(out.residuals[9], 0.0);
2007    }
2008
2009    /// L5: span (0.05) below 1/nobs (1/10) short-circuits to interpolation, so
2010    /// fitted == y and residuals are exactly zero regardless of weights.
2011    #[test]
2012    fn loess_fit_l5_tiny_span_identity() {
2013        let x: Vec<f64> = (1..=10).map(|i| i as f64).collect();
2014        let y: Vec<f64> = (1..=10).map(|i| (i as f64).powf(1.5)).collect();
2015        let w = vec![1.0; 10];
2016        let out = loess_fit(&y, &x, Some(&w), 0.05, 4, 1e-5, 1e5, true);
2017        for (f, yy) in out.fitted.iter().zip(y.iter()) {
2018            assert_eq!(f, yy);
2019        }
2020        for r in &out.residuals {
2021            assert_eq!(*r, 0.0);
2022        }
2023    }
2024}