Merge pull request #3323 from vangel76/fix/linest-qr

LINEST, LOGEST, TREND, GROWTH: solve by Householder QR (#3322)
This commit is contained in:
FredApps
2026-11-12 17:59:31 +01:00
committed by GitHub
2 changed files with 324 additions and 92 deletions
+145 -92
View File
@@ -55,12 +55,12 @@ fn strict_num(v: &Value) -> R<f64> {
}
}
/// Most predictor columns LINEST, LOGEST, TREND and GROWTH accept. The normal equations are a
/// k×k matrix inverted in O(k³), so an unbounded k (one `SEQUENCE(1,16384)` argument) allocated
/// Most predictor columns LINEST, LOGEST, TREND and GROWTH accept. The fit works on a k×k
/// triangle in O(k³), so an unbounded k (one `SEQUENCE(1,16384)` argument) allocated
/// gigabytes and could abort the process.
pub(super) const MAX_REGRESSORS: usize = 255;
/// Most multiply-adds (rows × k²) a regression may spend building its normal equations.
/// Most multiply-adds (rows × k²) a regression may spend on its factorisation.
const MAX_REGRESSION_WORK: usize = 1_000_000_000;
/// Rejects regressions too large to fit: more than [`MAX_REGRESSORS`] predictors is `#VALUE!`,
@@ -121,59 +121,136 @@ struct Fit {
ssresid: f64,
}
/// Inverts a symmetric positive semi-definite matrix by Gauss–Jordan on the diagonal, dropping
/// (zeroing) collinear variables. Returns the inverse and the dropped flags.
fn sym_inverse(m: &[Vec<f64>]) -> (Vec<Vec<f64>>, Vec<bool>) {
let p = m.len();
let mut a: Vec<Vec<f64>> = m.to_vec();
let mut inv: Vec<Vec<f64>> = (0..p).map(|i| (0..p).map(|j| if i == j { 1.0 } else { 0.0 }).collect()).collect();
let mut dropped = vec![false; p];
let diag: Vec<f64> = (0..p).map(|i| m.get(i).and_then(|r| r.get(i)).copied().unwrap_or(0.0).abs()).collect();
for j in 0..p {
let piv = a.get(j).and_then(|r| r.get(j)).copied().unwrap_or(0.0);
let scale = diag.get(j).copied().unwrap_or(0.0);
if piv.abs() <= 1e-12 * scale || piv.abs() < 1e-300 {
if let Some(d) = dropped.get_mut(j) {
*d = true;
}
for row in a.iter_mut() {
if let Some(v) = row.get_mut(j) {
*v = 0.0;
}
}
if let Some(r) = a.get_mut(j) {
r.iter_mut().for_each(|v| *v = 0.0);
}
if let Some(r) = inv.get_mut(j) {
r.iter_mut().for_each(|v| *v = 0.0);
}
/// Euclidean norm without intermediate overflow or underflow.
fn norm2(v: &[f64]) -> f64 {
let m = v.iter().fold(0.0f64, |m, x| m.max(x.abs()));
if m == 0.0 || !m.is_finite() {
return m;
}
m * v.iter().map(|x| (x / m) * (x / m)).sum::<f64>().sqrt()
}
/// Result of [`qr_solve`]: the least-squares solution on the kept columns, in the caller's
/// column scale, and what the standard errors need.
struct QrFit {
coef: Vec<f64>,
dropped: Vec<bool>,
/// Residual sum of squares of the fit.
ssresid: f64,
/// Diagonal of `(X'X)^-1` over the kept columns (0 for dropped ones).
diag: Vec<f64>,
/// `xm' (X'X)^-1 xm` over the kept columns.
quad: f64,
}
/// Least squares by Householder QR on unit-norm columns, instead of the normal equations, whose
/// condition number is the square of the data's. Columns are taken left to right; one whose part
/// outside the span of the kept columns is under 1e-6 of its own length (the 1e-12 relative
/// pivot of the earlier normal-equation solver, on squares) is collinear: it is dropped, with
/// coefficient and standard error 0, as Excel does.
///
/// `constant[j]` says column `j` was constant before centring. Centring it leaves rounding
/// residue, not 0 (`[0.1; 3]` centres to about -1.4e-17): it is collinear with the intercept, so
/// it is dropped instead of being scaled up to unit length. Whether a column is constant is
/// decided on its raw values, not by comparing the centred length with the offset: a predictor
/// like `[1e13, 1e13 + 1, 1e13 + 2]` is far smaller than its offset and still varies.
fn qr_solve(mut cols: Vec<Vec<f64>>, mut y: Vec<f64>, xm: &[f64], constant: &[bool]) -> QrFit {
let n = y.len();
let k = cols.len();
let mut scale = vec![1.0; k];
let mut dropped = vec![false; k];
// Householder vectors (unit norm, acting on rows `at..n`) and the columns of R.
let mut refl: Vec<(usize, Vec<f64>)> = Vec::new();
let mut r_cols: Vec<Vec<f64>> = Vec::new();
let mut kept: Vec<usize> = Vec::new();
for (j, col) in cols.iter_mut().enumerate() {
let len = norm2(col);
if constant.get(j).copied().unwrap_or(false) || !(len > 1e-300 && len.is_finite()) {
dropped[j] = true;
continue;
}
let arow: Vec<f64> = a.get(j).map(|r| r.iter().map(|v| v / piv).collect()).unwrap_or_default();
let irow: Vec<f64> = inv.get(j).map(|r| r.iter().map(|v| v / piv).collect()).unwrap_or_default();
for i in 0..p {
if i == j {
continue;
}
let f = a.get(i).and_then(|r| r.get(j)).copied().unwrap_or(0.0);
if f == 0.0 {
continue;
}
if let Some(r) = a.get_mut(i) {
r.iter_mut().zip(&arow).for_each(|(v, w)| *v -= f * w);
}
if let Some(r) = inv.get_mut(i) {
r.iter_mut().zip(&irow).for_each(|(v, w)| *v -= f * w);
}
col.iter_mut().for_each(|v| *v /= len);
scale[j] = len;
for (at, v) in &refl {
apply_reflector(*at, v, col);
}
if let Some(r) = a.get_mut(j) {
*r = arow;
let at = refl.len();
let tail = col.get(at..).unwrap_or(&[]);
let alpha = norm2(tail);
if alpha * alpha <= 1e-12 {
dropped[j] = true;
continue;
}
if let Some(r) = inv.get_mut(j) {
*r = irow;
let sign = if tail.first().is_some_and(|t| *t < 0.0) { -1.0 } else { 1.0 };
let mut v: Vec<f64> = tail.to_vec();
if let Some(h) = v.first_mut() {
*h += sign * alpha;
}
let vn = norm2(&v);
v.iter_mut().for_each(|x| *x /= vn);
let mut rc: Vec<f64> = col.get(..at).unwrap_or(&[]).to_vec();
rc.push(-sign * alpha);
r_cols.push(rc);
refl.push((at, v));
kept.push(j);
}
for (at, v) in &refl {
apply_reflector(*at, v, &mut y);
}
let r = kept.len();
// Back substitution R c = (Q'y)[..r], then undo the column scaling.
let mut c = vec![0.0; r];
for i in (0..r).rev() {
let mut t = y.get(i).copied().unwrap_or(0.0);
for jj in i + 1..r {
t -= r_cols.get(jj).and_then(|rc| rc.get(i)).copied().unwrap_or(0.0) * c.get(jj).copied().unwrap_or(0.0);
}
if let Some(ci) = c.get_mut(i) {
*ci = t / r_cols.get(i).and_then(|rc| rc.get(i)).copied().unwrap_or(f64::NAN);
}
}
(inv, dropped)
let ssresid = y.get(r..n).unwrap_or(&[]).iter().map(|v| v * v).sum::<f64>();
// R^-1 (upper triangular), row i of the inverse at rinv[i].
let rij = |i: usize, j: usize| r_cols.get(j).and_then(|rc| rc.get(i)).copied().unwrap_or(0.0);
let mut rinv = vec![vec![0.0; r]; r];
for t in 0..r {
for i in (0..=t).rev() {
let mut v = if i == t { 1.0 } else { 0.0 };
for jj in i + 1..=t {
v -= rij(i, jj) * rinv.get(jj).and_then(|row| row.get(t)).copied().unwrap_or(0.0);
}
if let Some(cell) = rinv.get_mut(i).and_then(|row| row.get_mut(t)) {
*cell = v / rij(i, i);
}
}
}
let mut coef = vec![0.0; k];
let mut diag = vec![0.0; k];
for (i, &j) in kept.iter().enumerate() {
let s = scale.get(j).copied().unwrap_or(1.0);
if let Some(v) = coef.get_mut(j) {
*v = c.get(i).copied().unwrap_or(0.0) / s;
}
let row = rinv.get(i);
if let Some(v) = diag.get_mut(j) {
*v = row.map_or(0.0, |rw| rw.iter().map(|x| x * x).sum::<f64>()) / (s * s);
}
}
// xm' (S R'R S)^-1 xm = |R^-T S^-1 xm|².
let w: Vec<f64> = kept.iter().map(|&j| xm.get(j).copied().unwrap_or(0.0) / scale.get(j).copied().unwrap_or(1.0)).collect();
let quad = (0..r)
.map(|t| {
(0..=t).map(|i| rinv.get(i).and_then(|row| row.get(t)).copied().unwrap_or(0.0) * w.get(i).copied().unwrap_or(0.0)).sum::<f64>().powi(2)
})
.sum::<f64>();
QrFit { coef, dropped, ssresid, diag, quad }
}
/// Applies the Householder reflection `I - 2vv'` on rows `at..` of `x`.
fn apply_reflector(at: usize, v: &[f64], x: &mut [f64]) {
let Some(tail) = x.get_mut(at..) else { return };
let dot: f64 = v.iter().zip(tail.iter()).map(|(a, b)| a * b).sum();
tail.iter_mut().zip(v).for_each(|(t, a)| *t -= 2.0 * dot * a);
}
fn regress(y: &[f64], x: &[Vec<f64>], k: usize, constant: bool) -> R<Fit> {
@@ -189,35 +266,22 @@ fn regress(y: &[f64], x: &[Vec<f64>], k: usize, constant: bool) -> R<Fit> {
} else {
(vec![0.0; k], 0.0)
};
// Normal equations on (centred) data.
let mut xtx = vec![vec![0.0; k]; k];
let mut xty = vec![0.0; k];
for (row, yv) in x.iter().zip(y) {
let d: Vec<f64> = (0..k).map(|j| row.get(j).copied().unwrap_or(0.0) - xm.get(j).copied().unwrap_or(0.0)).collect();
let yd = yv - ym;
for (i, di) in d.iter().enumerate() {
if let Some(t) = xty.get_mut(i) {
*t += di * yd;
}
if let Some(r) = xtx.get_mut(i) {
for (j, dj) in d.iter().enumerate() {
if let Some(v) = r.get_mut(j) {
*v += di * dj;
}
}
}
}
}
let (inv, dropped) = sym_inverse(&xtx);
let coef: Vec<f64> = (0..k).map(|i| inv.get(i).map(|r| r.iter().zip(&xty).map(|(a, b)| a * b).sum()).unwrap_or(0.0)).collect();
// (Centred) predictor columns and response.
let cols: Vec<Vec<f64>> =
(0..k).map(|j| x.iter().map(|r| r.get(j).copied().unwrap_or(0.0) - xm.get(j).copied().unwrap_or(0.0)).collect()).collect();
let yc: Vec<f64> = y.iter().map(|v| v - ym).collect();
let sstot: f64 = if constant { yc.iter().map(|v| v * v).sum() } else { y.iter().map(|v| v * v).sum() };
// Only centring leaves rounding residue behind; without an intercept a constant column is data.
// Constant means every raw value is the same double: distinct values, however close, vary.
let constant_cols: Vec<bool> = (0..k)
.map(|j| {
let mut col = x.iter().map(|r| r.get(j).copied().unwrap_or(0.0));
let first = col.next();
constant && first.is_some_and(|f| col.all(|v| v == f))
})
.collect();
let QrFit { coef, dropped, ssresid, diag, quad } = qr_solve(cols, yc, &xm, &constant_cols);
let b = if constant { ym - coef.iter().zip(&xm).map(|(c, m)| c * m).sum::<f64>() } else { 0.0 };
let mut ssresid = 0.0;
let mut sstot = 0.0;
for (row, yv) in x.iter().zip(y) {
let pred = b + coef.iter().zip(row).map(|(c, v)| c * v).sum::<f64>();
ssresid += (yv - pred).powi(2);
sstot += if constant { (yv - ym).powi(2) } else { yv * yv };
}
let used = dropped.iter().filter(|d| !**d).count() + usize::from(constant);
let df = nf - used as f64;
let ssreg = sstot - ssresid;
@@ -230,20 +294,9 @@ fn regress(y: &[f64], x: &[Vec<f64>], k: usize, constant: bool) -> R<Fit> {
_ => Err(CellError::Num),
};
let s2 = sey2.unwrap_or(f64::NAN);
let se: Vec<f64> = (0..k)
.map(|i| if dropped.get(i).copied().unwrap_or(true) { 0.0 } else { (inv.get(i).and_then(|r| r.get(i)).copied().unwrap_or(0.0) * s2).sqrt() })
.collect();
let se_b = if constant {
let mut q = 0.0;
for (i, mi) in xm.iter().enumerate() {
for (j, mj) in xm.iter().enumerate() {
q += mi * mj * inv.get(i).and_then(|r| r.get(j)).copied().unwrap_or(0.0);
}
}
Some((s2 * (1.0 / nf + q)).sqrt())
} else {
None
};
let se: Vec<f64> =
(0..k).map(|i| if dropped.get(i).copied().unwrap_or(true) { 0.0 } else { (diag.get(i).copied().unwrap_or(0.0) * s2).sqrt() }).collect();
let se_b = if constant { Some((s2 * (1.0 / nf + quad)).sqrt()) } else { None };
Ok(Fit { coef, b, se, se_b, r2, sey, f, df, ssreg, ssresid })
}
+179
View File
@@ -433,6 +433,185 @@ fn linest_family() {
is_err(ev("LINEST", vec![c(&[1.0, 2.0]), c(&[1.0, 2.0, 3.0])]), CellError::Ref);
}
/// LINEST on columns of predictors, `stats` on, as rows of numbers (`None` for an error cell).
fn linest_rows(y: &[f64], xs: &[Vec<f64>], constant: bool) -> Vec<Vec<Option<f64>>> {
let x = arr((0..y.len()).map(|i| xs.iter().map(|col| nv(col[i])).collect()).collect());
let r = rows_of(&ev("LINEST", vec![c(y), av(x), b(constant), b(true)]));
r.iter().map(|row| row.iter().map(Value::as_f64).collect()).collect()
}
fn rel_close(got: Option<f64>, want: f64, tol: f64, what: &str) {
let got = got.unwrap_or(f64::NAN);
assert!((got - want).abs() <= tol * want.abs().max(1e-300), "{what}: got {got:e}, want {want:e}, rel tol {tol:e}");
}
// B2: LINEST solved the normal equations, whose condition number is the square of the data's.
// Expected values below come from exact rational arithmetic: the normal equations solved with
// Python `fractions.Fraction` (no rounding at all), rounded once to f64 (script in the PR text).
#[test]
fn linest_ill_conditioned_polynomial_is_exact() {
// y = 1 + x + x² + x³ + x⁴ + x⁵ on x = 1..12: every coefficient is exactly 1. The normal
// equations were off by up to 2.6e-6.
let xs: Vec<f64> = (1..=12).map(f64::from).collect();
let ys: Vec<f64> = xs.iter().map(|x| (0..=5).map(|j| x.powi(j)).sum()).collect();
let cols: Vec<Vec<f64>> = (1..=5).map(|j| xs.iter().map(|x| x.powi(j)).collect()).collect();
let r = linest_rows(&ys, &cols, true);
for (i, v) in r[0].iter().enumerate() {
rel_close(*v, 1.0, 1e-9, &format!("coefficient {i}"));
}
}
#[test]
fn linest_large_offset_regressors_recover_the_line() {
// y = 3x + 7 exactly with x = 1e6 + i and regressors [x, x²]: the true fit has x² coefficient
// 0, x coefficient 3, intercept 7. The normal equations returned 2.99976 and 251.14.
let xs: Vec<f64> = (0..12).map(|i| 1_000_000.0 + f64::from(i)).collect();
let ys: Vec<f64> = xs.iter().map(|x| 3.0 * x + 7.0).collect();
let r = linest_rows(&ys, &[xs.clone(), xs.iter().map(|x| x * x).collect()], true);
assert!(r[0][0].unwrap().abs() < 1e-12, "x² coefficient {:?}", r[0][0]);
rel_close(r[0][1], 3.0, 1e-9, "x coefficient");
// The intercept subtracts 3e6 from 3e6 + 7 and carries the x² coefficient times 1e12, so
// it is good to about 1e-4 in absolute terms, not better.
assert!((r[0][2].unwrap() - 7.0).abs() < 1e-3, "intercept {:?}", r[0][2]);
// One regressor: exact.
let one = linest_rows(&ys, std::slice::from_ref(&xs), true);
rel_close(one[0][0], 3.0, 1e-12, "slope");
rel_close(one[0][1], 7.0, 1e-9, "intercept");
}
#[test]
fn linest_noisy_fits_match_exact_rational_least_squares() {
// Two predictors with noise, constant fitted. Order of each row: x2, x1, intercept.
let x1 = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
let x2 = [2.0, 1.0, 4.0, 3.0, 7.0, 5.0, 9.0, 6.0];
let y = [5.0, 6.0, 11.0, 12.0, 20.0, 19.0, 30.0, 28.0];
let r = linest_rows(&y, &[x1.to_vec(), x2.to_vec()], true);
let (coef, se) = ([1.4270152505446623, 2.452069716775599, -1.2592592592592593], [0.3376442444771937, 0.3679392851360571, 1.0847489958915801]);
for i in 0..3 {
rel_close(r[0][i], coef[i], 1e-12, "coef");
rel_close(r[1][i], se[i], 1e-12, "se");
}
rel_close(r[2][0], 0.985070139277778, 1e-12, "r2");
rel_close(r[2][1], 1.367057173604725, 1e-12, "sey");
rel_close(r[3][0], 164.94965318256004, 1e-11, "F");
assert_eq!(r[3][1], Some(5.0));
rel_close(r[4][0], 616.5307734204792, 1e-12, "ssreg");
rel_close(r[4][1], 9.344226579520697, 1e-11, "ssresid");
assert_eq!(r[2][2], None);
// const = FALSE: through the origin, R² and F from the uncentred sums.
let r = linest_rows(&y, &[x1.to_vec(), x2.to_vec()], false);
rel_close(r[0][0], 1.3529411764705883, 1e-12, "coef");
rel_close(r[0][1], 2.303921568627451, 1e-12, "coef");
assert_eq!(r[0][2], Some(0.0));
rel_close(r[1][0], 0.34102994374364876, 1e-12, "se");
rel_close(r[1][1], 0.35495521934550084, 1e-12, "se");
assert_eq!(r[1][2], None);
rel_close(r[2][0], 0.995718966041848, 1e-12, "r2");
rel_close(r[2][1], 1.4061024795535124, 1e-12, "sey");
rel_close(r[3][0], 697.7652892561983, 1e-11, "F");
assert_eq!(r[3][1], Some(6.0));
rel_close(r[4][0], 2759.1372549019607, 1e-12, "ssreg");
rel_close(r[4][1], 11.862745098039216, 1e-11, "ssresid");
// Cubic and quintic with noise on x = 1..12; order x^k .. x, intercept.
let xs: Vec<f64> = (1..=12).map(f64::from).collect();
let noise = [3.0, -2.0, 4.0, -1.0, 2.0, -3.0, 1.0, -4.0, 2.0, 3.0, -2.0, 1.0];
let cub: Vec<f64> = xs.iter().zip(noise).map(|(x, e)| 1.0 + x + x * x + x.powi(3) + e).collect();
let r = linest_rows(&cub, &(1..=3).map(|j| xs.iter().map(|x| x.powi(j)).collect()).collect::<Vec<Vec<f64>>>(), true);
let coef = [0.9955969955969955, 1.1452991452991452, -0.33514633514633513, 4.373737373737374];
let se = [0.02755463331301788, 0.5434124199147191, 3.102549978874372, 4.851885058051787];
for i in 0..4 {
rel_close(r[0][i], coef[i], 1e-11, "cubic coef");
rel_close(r[1][i], se[i], 1e-10, "cubic se");
}
rel_close(r[2][0], 0.9999837292113533, 1e-12, "cubic r2");
rel_close(r[4][1], 70.35586635586635, 1e-9, "cubic ssresid");
let quint: Vec<f64> = xs.iter().zip(noise).map(|(x, e)| (0..=5).map(|j| x.powi(j)).sum::<f64>() + 50.0 * e).collect();
let r = linest_rows(&quint, &(1..=5).map(|j| xs.iter().map(|x| x.powi(j)).collect()).collect::<Vec<Vec<f64>>>(), true);
let coef = [0.9330693815987934, 3.022273070067188, -20.97398190045249, 108.20489681886741, -250.24940010969422, 271.45454545454544];
let se = [0.20046539191103213, 6.538253746916523, 78.39635356777087, 421.2608646012634, 976.3504976093745, 736.5866411764556];
for i in 0..6 {
rel_close(r[0][i], coef[i], 1e-8, "quintic coef");
rel_close(r[1][i], se[i], 1e-8, "quintic se");
}
rel_close(r[2][1], 168.58174051770763, 1e-9, "quintic sey");
rel_close(r[4][1], 170518.81941587824, 1e-7, "quintic ssresid");
}
#[test]
fn linest_drops_collinear_columns() {
// x2 = 2·x1: the later column is dropped (coefficient and standard error 0), the fit is the
// one on x1 alone, and the degrees of freedom count only the kept column.
let x1 = [1.0, 2.0, 3.0, 4.0, 5.0];
let y = [2.0, 4.0, 5.0, 4.0, 5.0];
let x2: Vec<f64> = x1.iter().map(|v| 2.0 * v).collect();
let r = linest_rows(&y, &[x1.to_vec(), x2], true);
// Exact: slope 0.6, intercept 2.2, ssresid 2.4, ssreg 3.6, df 3 (Fraction arithmetic).
assert_eq!(r[0][0], Some(0.0));
rel_close(r[0][1], 0.6, 1e-12, "slope");
rel_close(r[0][2], 2.2, 1e-12, "intercept");
assert_eq!(r[1][0], Some(0.0));
rel_close(r[1][1], (0.8f64 / 10.0).sqrt(), 1e-12, "se slope");
assert_eq!(r[3][1], Some(3.0));
rel_close(r[4][0], 3.6, 1e-12, "ssreg");
rel_close(r[4][1], 2.4, 1e-12, "ssresid");
// A constant column is collinear with the intercept and dropped as well.
let r = linest_rows(&y, &[vec![7.0; 5], x1.to_vec()], true);
assert_eq!(r[0][1], Some(0.0));
assert_eq!(r[1][1], Some(0.0));
rel_close(r[0][0], 0.6, 1e-12, "slope");
rel_close(r[0][2], 2.2, 1e-12, "intercept");
// A constant column whose mean does not centre to exactly 0 (the mean of three 0.1 is
// 0.10000000000000002) is just as collinear with the intercept: dropped, not scaled up to a
// unit vector. Some length hits a rounding mean for each constant.
for c in [0.1, 1.1, 1e6 + 0.3] {
for n in 3..=12 {
let xs: Vec<f64> = (1..=n).map(f64::from).collect();
let ys: Vec<f64> = (1..=n).map(|i| f64::from(i * i % 7) + 0.5 * f64::from(i)).collect();
let r = linest_rows(&ys, &[vec![c; n as usize], xs], true);
assert_eq!(r[0][1], Some(0.0), "coefficient of the constant {c}, n={n}");
assert_eq!(r[1][1], Some(0.0), "standard error of the constant {c}, n={n}");
assert_eq!(r[3][1], Some(f64::from(n) - 2.0), "df with the constant {c}, n={n}");
}
}
// A column that merely sits far from zero is not noise and is kept.
let off: Vec<f64> = x1.iter().map(|v| 1e9 + v).collect();
let r = linest_rows(&y, &[off], true);
rel_close(r[0][0], 0.6, 1e-6, "slope on an offset column");
assert_eq!(r[3][1], Some(3.0));
// Distinct values far from zero, whose spread is below 1e-12 of the offset, still vary.
let r = linest_rows(&[0.0, 1.0, 2.0], &[vec![1e13, 1e13 + 1.0, 1e13 + 2.0]], true);
rel_close(r[0][0], 1.0, 1e-9, "slope on a predictor with a 1e13 offset");
assert_eq!(r[3][1], Some(1.0), "df: the predictor is kept");
// Same at 1e15, where the spread is only a few ulps of the offset: still distinct doubles.
let x15 = [1e15, 1e15 + 1.0, 1e15 + 2.0];
let r = linest_rows(&[0.0, 1.0, 2.0], &[x15.to_vec()], true);
rel_close(r[0][0], 1.0, 1e-9, "slope on a predictor with a 1e15 offset");
rel_close(r[0][1], -1e15, 1e-9, "intercept on a predictor with a 1e15 offset");
assert_eq!(r[3][1], Some(1.0), "df: the 1e15 predictor is kept");
assert!(r[4][1].is_some_and(|v| v.abs() < 1e-9), "ssresid: the 1e15 line is exact, got {:?}", r[4][1]);
let fitted = rows_of(&ev("TREND", vec![c(&[0.0, 1.0, 2.0]), c(&x15)]));
close(fitted[2][0].clone(), 2.0);
// More predictors than observations: the surplus columns drop, no panic.
let r = linest_rows(&[1.0, 2.0], &[vec![1.0, 2.0], vec![2.0, 1.0], vec![0.0, 5.0]], true);
assert_eq!(r[0].len(), 4);
}
#[test]
fn logest_is_linest_of_ln_y() {
// y = 5·2^x: m = 2, b = 5.
let x = [0.0, 1.0, 2.0, 3.0, 4.0];
let y: Vec<f64> = x.iter().map(|v| 5.0 * 2f64.powf(*v)).collect();
let l = rows_of(&ev("LOGEST", vec![c(&y), c(&x)]));
close_tol(l[0][0].clone(), 2.0, 1e-12);
close_tol(l[0][1].clone(), 5.0, 1e-12);
// The statistics are those of the fit on ln y, untransformed.
let ln: Vec<f64> = y.iter().map(|v| v.ln()).collect();
let ls = rows_of(&ev("LOGEST", vec![c(&y), c(&x), b(true), b(true)]));
let ns = rows_of(&ev("LINEST", vec![c(&ln), c(&x), b(true), b(true)]));
assert_eq!(ls[1..], ns[1..]);
close_tol(ls[0][0].clone(), ns[0][0].as_f64().unwrap().exp(), 1e-14);
}
// #2177: one predictor row of 16,384 columns built three 16,384² matrices (6 GB) and an O(k³)
// inversion; a wider TOROW aborted the process. It is now refused before allocating.
#[test]