// Copyright (C) 2026 Kiyotsugu Arai // SPDX-License-Identifier: LGPL-3.0-or-later // approximation.hpp // Function approximation algorithms // // Function list: // linearRegression(x, y) → LinearRegressionResult // linearRegression(y) → LinearRegressionResult (index-based) // weightedLinearRegression(x,y,w) → LinearRegressionResult // chebyshevApprox(f, a, b, n) → ChebyshevApprox (Chebyshev approximation) // padeApprox(taylor, M, N) → PadeResult (Padé approximation) // padeTable(taylor, Mmax, Nmax) → vector>> (Padé table) // taylorToSFraction(taylor, kmax) → StieltjesFraction (qd-algorithm) // taylorToJFraction(taylor, kmax) → JacobiFraction (via qd-algorithm) // evalSFraction(sf, x), evalJFraction(jf, x) (continued-fraction evaluation) // rationalInterpolation(x, y, M, N) → RationalInterpResult (multipoint Padé / Cauchy) // evalRational(result, x) (rational function evaluation) // matrixExpPade(A, m=6) → Matrix (matrix exponential e^A, Scaling-and-Squaring) // polynomialFit(x, y, degree) → std::vector (least-squares polynomial approximation) // // Selection guide: // - Priority on compute cost / easy coefficient analysis: chebyshevApprox // - Taylor coefficients known + accuracy improvement: padeApprox // - Computing e^A: matrixExpPade (Scaling-and-Squaring, m=6 is standard accuracy) // - Fitting a polynomial to data points: polynomialFit (note: high degree worsens the condition number) #ifndef SANGI_APPROXIMATION_HPP #define SANGI_APPROXIMATION_HPP #include #include #include #include #include #include #include #include #include // algorithms::expm (matrixExpPade delegates here) #include #include #include #include #include namespace sangi { // ===================================================================== // Result structures // ===================================================================== /** * @brief Result of linear regression * @note slope/intercept/R² are now made explicit */ template struct LinearRegressionResult { T slope; // slope a T intercept; // intercept b (y = a*x + b) T r_squared; // coefficient of determination R² }; // ===================================================================== // Linear regression // ===================================================================== /** * @brief Linear regression y = a*x + b * @param x array of independent variables * @param y array of dependent variables (same size as x) * @return LinearRegressionResult{slope, intercept, r_squared} * @throw std::invalid_argument on size mismatch, or fewer than 2 elements */ template [[nodiscard]] LinearRegressionResult linearRegression( std::span x, std::span y) { if (x.size() != y.size()) { throw std::invalid_argument("linearRegression: x and y must have the same size"); } if (x.size() < 2) { throw std::invalid_argument("linearRegression: need at least 2 data points"); } std::size_t n = x.size(); T n_val = static_cast(n); // means T mx = T(0), my = T(0); for (std::size_t i = 0; i < n; ++i) { mx += x[i]; my += y[i]; } mx /= n_val; my /= n_val; // Sxx, Sxy, Syy T sxx = T(0), sxy = T(0), syy = T(0); for (std::size_t i = 0; i < n; ++i) { T dx = x[i] - mx; T dy = y[i] - my; sxx += dx * dx; sxy += dx * dy; syy += dy * dy; } if (sxx == T(0)) { throw std::invalid_argument("linearRegression: all x values are identical"); } T slope = sxy / sxx; T intercept = my - slope * mx; // R² = Sxy² / (Sxx * Syy) T r_squared = (syy == T(0)) ? T(1) : (sxy * sxy) / (sxx * syy); return {slope, intercept, r_squared}; } template [[nodiscard]] LinearRegressionResult linearRegression( const std::vector& x, const std::vector& y) { return linearRegression(std::span(x), std::span(y)); } /** * @brief Index-based linear regression y = a*i + b (i = 0, 1, ..., n-1) * @param y array of dependent variables * @return LinearRegressionResult{slope, intercept, r_squared} */ template [[nodiscard]] LinearRegressionResult linearRegression(std::span y) { if (y.size() < 2) { throw std::invalid_argument("linearRegression: need at least 2 data points"); } std::vector x(y.size()); for (std::size_t i = 0; i < y.size(); ++i) { x[i] = static_cast(i); } return linearRegression(std::span(x), y); } template [[nodiscard]] LinearRegressionResult linearRegression(const std::vector& y) { return linearRegression(std::span(y)); } /** * @brief Weighted linear regression y = a*x + b * @param x independent variable * @param y dependent variable * @param weights weights * @return LinearRegressionResult{slope, intercept, r_squared} * @note stabilized with algorithms::solve */ template [[nodiscard]] LinearRegressionResult weightedLinearRegression( std::span x, std::span y, std::span weights) { if (x.size() != y.size() || x.size() != weights.size()) { throw std::invalid_argument("weightedLinearRegression: x, y, weights must have the same size"); } if (x.size() < 2) { throw std::invalid_argument("weightedLinearRegression: need at least 2 data points"); } // Normal equations: [Σw*x², Σw*x; Σw*x, Σw] [a; b] = [Σw*x*y; Σw*y] T wxx = T(0), wx = T(0), w_sum = T(0), wxy = T(0), wy = T(0); for (std::size_t i = 0; i < x.size(); ++i) { T wi = weights[i]; wxx += wi * x[i] * x[i]; wx += wi * x[i]; w_sum += wi; wxy += wi * x[i] * y[i]; wy += wi * y[i]; } if (w_sum == T(0)) { throw std::invalid_argument("weightedLinearRegression: sum of weights must not be zero"); } // Solve the 2x2 linear system with algorithms::solve Matrix A(2, 2); A(0, 0) = wxx; A(0, 1) = wx; A(1, 0) = wx; A(1, 1) = w_sum; Vector b(2); b[0] = wxy; b[1] = wy; Vector solution = algorithms::solve(A, b, algorithms::SolverType::LU); T slope = solution[0]; T intercept = solution[1]; // weighted R² T wmx = wx / w_sum; T wmy = wy / w_sum; T ss_tot = T(0), ss_res = T(0); for (std::size_t i = 0; i < x.size(); ++i) { T wi = weights[i]; T y_pred = slope * x[i] + intercept; ss_res += wi * (y[i] - y_pred) * (y[i] - y_pred); ss_tot += wi * (y[i] - wmy) * (y[i] - wmy); } T r_squared = (ss_tot == T(0)) ? T(1) : T(1) - ss_res / ss_tot; return {slope, intercept, r_squared}; } template [[nodiscard]] LinearRegressionResult weightedLinearRegression( const std::vector& x, const std::vector& y, const std::vector& weights) { return weightedLinearRegression( std::span(x), std::span(y), std::span(weights)); } // ===================================================================== // Chebyshev approximation // ===================================================================== /** * @brief Class holding the result of a Chebyshev approximation * * Approximates a function f by a degree-n Chebyshev polynomial on [a, b]. * The Chebyshev coefficients are computed at construction, and operator() evaluates the approximation. * * @note Improvements: * - class template now has concepts constraints * - function pointer → std::function * - assert → exception * - toCoefficients() can convert to ordinary polynomial coefficients */ template class ChebyshevApprox { public: /** * @brief Construct a Chebyshev approximation * @param f function to approximate * @param a left endpoint of the interval * @param b right endpoint of the interval * @param n degree of the Chebyshev polynomial (≥ 0) */ ChebyshevApprox(const std::function& f, T a, T b, std::size_t n) : m_a(a), m_b(b), m_n(n + 1), m_c(n + 1) { if (a >= b) { throw std::invalid_argument("ChebyshevApprox: a must be less than b"); } const T pi = std::acos(T(-1)); const T bma = T(0.5) * (b - a); const T bpa = T(0.5) * (b + a); const std::size_t N = m_n; // Function values at the Chebyshev nodes std::vector fvals(N); for (std::size_t k = 0; k < N; ++k) { T y = std::cos(pi * (static_cast(k) + T(0.5)) / static_cast(N)); fvals[k] = f(y * bma + bpa); } // Compute the Chebyshev coefficients (DCT-II) T fac = T(2) / static_cast(N); for (std::size_t j = 0; j < N; ++j) { T sum = T(0); for (std::size_t k = 0; k < N; ++k) { sum += fvals[k] * std::cos(pi * static_cast(j) * (static_cast(k) + T(0.5)) / static_cast(N)); } m_c[j] = fac * sum; } } /** * @brief Evaluate the approximation (Clenshaw recurrence) * @param x evaluation point (inside the interval [a, b]) * @return approximate value */ [[nodiscard]] T operator()(T x) const { return evaluate(x, m_n); } /** * @brief Evaluate the approximation using only the first m terms (truncation) * @param x evaluation point * @param m number of terms to use (≤ m_n) * @return approximate value */ [[nodiscard]] T evaluate(T x, std::size_t m) const { if (m > m_n) m = m_n; if (m == 0) return T(0); // Map x to [-1, 1] T y = (T(2) * x - m_a - m_b) / (m_b - m_a); // Clenshaw recurrence T d = T(0), dd = T(0); T y2 = T(2) * y; for (std::size_t j = m - 1; j >= 1; --j) { T sv = d; d = y2 * d - dd + m_c[j]; dd = sv; } return y * d - dd + T(0.5) * m_c[0]; } /** @brief Get the Chebyshev coefficients */ [[nodiscard]] const std::vector& coefficients() const { return m_c; } /** @brief Get the number of terms */ [[nodiscard]] std::size_t size() const { return m_n; } /** @brief Get the interval */ [[nodiscard]] T lower() const { return m_a; } [[nodiscard]] T upper() const { return m_b; } /** * @brief Convert to ordinary polynomial coefficients [a0, a1, ..., aN] * * Computes the coefficients of p(x) = Σ a_j * x^j from the Chebyshev coefficients c_k. * Includes the change of variable [a,b] → [-1,1]. * @return polynomial coefficients (a0 + a1*x + ... + aN*x^N) */ [[nodiscard]] std::vector toPolynomialCoefficients() const { // Convert the Chebyshev polynomial expansion to an ordinary polynomial // T_0(y) = 1, T_1(y) = y, T_{k+1}(y) = 2*y*T_k(y) - T_{k-1}(y) // where y = (2*x - a - b) / (b - a) const std::size_t N = m_n; // First build the polynomial coefficients on [-1,1] // d[k][j] = degree-j coefficient of T_k(y) std::vector> tcheb(N); tcheb[0].assign(1, T(1)); // T_0 = 1 if (N > 1) { tcheb[1].assign(2, T(0)); tcheb[1][1] = T(1); // T_1 = y } for (std::size_t k = 2; k < N; ++k) { tcheb[k].assign(k + 1, T(0)); // T_k = 2*y*T_{k-1} - T_{k-2} for (std::size_t j = 0; j < tcheb[k - 1].size(); ++j) { if (j + 1 < tcheb[k].size()) tcheb[k][j + 1] += T(2) * tcheb[k - 1][j]; } for (std::size_t j = 0; j < tcheb[k - 2].size(); ++j) { tcheb[k][j] -= tcheb[k - 2][j]; } } // Weighted sum with the Chebyshev coefficients (c[0]/2 * T_0 + c[1]*T_1 + ...) std::vector poly_y(N, T(0)); for (std::size_t k = 0; k < N; ++k) { T weight = (k == 0) ? m_c[0] / T(2) : m_c[k]; for (std::size_t j = 0; j < tcheb[k].size(); ++j) { poly_y[j] += weight * tcheb[k][j]; } } // Apply the change of variable y = (2*x - a - b) / (b - a) // y = alpha * x + beta, alpha = 2/(b-a), beta = -(a+b)/(b-a) T alpha = T(2) / (m_b - m_a); T beta = -(m_a + m_b) / (m_b - m_a); // Substitute (alpha*x + beta) for y in p(y) // Result: poly_x[j] = coefficient of x^j std::vector poly_x(N, T(0)); // Compute the coefficients of (alpha*x + beta)^k by binomial expansion // powers_of_ax_plus_b[k][j] = degree-j coefficient of (alpha*x + beta)^k std::vector current(1, T(1)); // (alpha*x + beta)^0 = 1 for (std::size_t k = 0; k < N; ++k) { // Add poly_y[k] * current into poly_x for (std::size_t j = 0; j < current.size(); ++j) { poly_x[j] += poly_y[k] * current[j]; } // current *= (alpha*x + beta) std::vector next(current.size() + 1, T(0)); for (std::size_t j = 0; j < current.size(); ++j) { next[j] += beta * current[j]; next[j + 1] += alpha * current[j]; } current = std::move(next); } return poly_x; } private: T m_a, m_b; std::size_t m_n; // number of terms (degree + 1) std::vector m_c; // Chebyshev coefficients }; // ===================================================================== // Padé approximation // ===================================================================== /** * @brief Result of a Padé approximation * @note Form p(x)/q(x). q[0] = 1 (normalized) */ template struct PadeResult { std::vector numerator; // numerator polynomial coefficients [p0, p1, ..., pM] std::vector denominator; // denominator polynomial coefficients [1, q1, ..., qN] bool valid; // whether a solution was obtained }; /** * @brief Padé approximation — build a rational-function approximation from a Taylor series * * Computes the [M/N] Padé approximation from Taylor coefficients a[0], a[1], ..., a[M+N]. * The result is p(x)/q(x), normalized so that q(0) = 1. * * @param taylor Taylor expansion coefficients (at least M+N+1 needed) * @param M degree of the numerator polynomial * @param N degree of the denominator polynomial * @return PadeResult{numerator, denominator, valid} * * @note Improvements: * - removed cout debug output * - stable linear-system solving with algorithms::solve * - self-contained, no dependency on a rational-expression class */ template [[nodiscard]] PadeResult padeApprox( std::span taylor, std::size_t M, std::size_t N) { const std::size_t L = M + N; if (taylor.size() < L + 1) { throw std::invalid_argument("padeApprox: need at least M+N+1 Taylor coefficients"); } // Linear system: L+1 unknowns [p0, ..., pM, q1, ..., qN] // Condition: Σ_{k=0}^{M} p_k * x^k = (Σ_{j=0}^{L} a_j * x^j) * (1 + Σ_{l=1}^{N} q_l * x^l) mod x^{L+1} // Expanding this, at each degree m: // m ≤ M: p_m + Σ_{l=1}^{min(m,N)} q_l * a_{m-l} = a_m // m > M: Σ_{l=1}^{min(m,N)} q_l * a_{m-l} = a_m (where m-l ≥ 0) // First solve for q (a linear system in N unknowns) if (N == 0) { // denominator = 1 → numerator = the Taylor coefficients as-is std::vector num(taylor.begin(), taylor.begin() + static_cast(M + 1)); return {num, {T(1)}, true}; } // Linear system for q: for degrees M+1, M+2, ..., M+N // a_{m} + q_1 * a_{m-1} + q_2 * a_{m-2} + ... + q_N * a_{m-N} = 0 (m = M+1..M+N) Matrix A(static_cast::size_type>(N), static_cast::size_type>(N)); Vector b(static_cast::size_type>(N)); for (std::size_t i = 0; i < N; ++i) { std::size_t m = M + 1 + i; for (std::size_t j = 0; j < N; ++j) { std::size_t idx = m - 1 - j; // a_{m-(j+1)} A(static_cast::size_type>(i), static_cast::size_type>(j)) = (idx <= L) ? taylor[idx] : T(0); } b[static_cast::size_type>(i)] = -taylor[m]; } // Solve for q Vector q_vec(static_cast::size_type>(0)); try { q_vec = algorithms::solve(A, b, algorithms::SolverType::LU); } catch (...) { return {{}, {}, false}; } // Denominator coefficients: [1, q1, q2, ..., qN] std::vector denom(N + 1); denom[0] = T(1); for (std::size_t j = 0; j < N; ++j) { denom[j + 1] = q_vec[static_cast::size_type>(j)]; } // Numerator coefficients: p_m = a_m + q_1*a_{m-1} + ... + q_min(m,N)*a_{m-min(m,N)} std::vector num(M + 1); for (std::size_t m = 0; m <= M; ++m) { T val = taylor[m]; for (std::size_t l = 1; l <= std::min(m, N); ++l) { val += denom[l] * taylor[m - l]; } num[m] = val; } return {num, denom, true}; } template [[nodiscard]] PadeResult padeApprox( const std::vector& taylor, std::size_t M, std::size_t N) { return padeApprox(std::span(taylor), M, N); } /** * @brief Evaluate a Padé approximation * @param result result of padeApprox * @param x evaluation point * @return p(x) / q(x) */ template [[nodiscard]] T evaluatePade(const PadeResult& result, T x) { // Evaluate numerator and denominator by Horner's method T num = result.numerator.back(); for (std::size_t i = result.numerator.size() - 1; i > 0; --i) { num = num * x + result.numerator[i - 1]; } T den = result.denominator.back(); for (std::size_t i = result.denominator.size() - 1; i > 0; --i) { den = den * x + result.denominator[i - 1]; } return num / den; } // ===================================================================== // Padé table — generate the [M/N] triangular array in one pass // ===================================================================== /** * @brief Padé table — compute the Padé approximation for all (M, N) with 0 ≤ M ≤ Mmax, 0 ≤ N ≤ Nmax * * From the input Taylor coefficients, returns [M/N] Padé as a table with rows M and columns N. * table[M][N] is a PadeResult, laid out so that the diagonal [M/M] and antidiagonal [M+N=const] * are easy to compare. * * Degenerate cells (valid=false from ill-conditioning) are returned as-is with valid=false. * * @param taylor Taylor coefficients (at least Mmax + Nmax + 1) * @param Mmax maximum numerator degree * @param Nmax maximum denominator degree * @return a (Mmax+1) × (Nmax+1) two-dimensional array, table[m][n] = [m/n] Padé * * @note Complexity is O(Mmax · Nmax · N²) (each cell solves an N×N system). * For large tables, when you only want e.g. the diagonal [M/M], it is often cheaper * to call padeApprox directly. */ template [[nodiscard]] std::vector>> padeTable(std::span taylor, std::size_t Mmax, std::size_t Nmax) { if (taylor.size() < Mmax + Nmax + 1) throw std::invalid_argument("padeTable: need at least Mmax+Nmax+1 Taylor coefficients"); std::vector>> table(Mmax + 1); for (std::size_t m = 0; m <= Mmax; ++m) { table[m].reserve(Nmax + 1); for (std::size_t n = 0; n <= Nmax; ++n) { try { table[m].push_back(padeApprox(taylor, m, n)); } catch (...) { table[m].push_back({{}, {}, false}); } } } return table; } template [[nodiscard]] std::vector>> padeTable(const std::vector& taylor, std::size_t Mmax, std::size_t Nmax) { return padeTable(std::span(taylor), Mmax, Nmax); } // ===================================================================== // Continued-fraction conversions — Stieltjes (S-fraction) / Jacobi (J-fraction) // ===================================================================== /** * @brief Stieltjes continued fraction (S-fraction) * * f(z) = c_0 / (1 + a_1 z / (1 + a_2 z / (1 + a_3 z / ...))) * * When terminated (terminated=true), a 0 appears somewhere in a and everything after is truncated. * A pure polynomial f is represented with a empty (only b0). */ template struct StieltjesFraction { T b0; // c_0 (leading constant, usually taylor[0]) std::vector a; // a_1, a_2, a_3, ... (sign-flipped qd coefficients) bool terminated; // whether the qd table hit 0 partway and the continued fraction completed at finite length int order; // number of input Taylor coefficients - 1 }; /** * @brief Jacobi continued fraction (J-fraction) * * f(z) = c_0 / (1 - β_0 z - α_1² z² / (1 - β_1 z - α_2² z² / ...)) * * Corresponds to the three-term recurrence of orthogonal polynomials. Convergence of the J-fraction * up to stage n equals the diagonal [n/n] Padé approximation. It groups two S-fraction stages at a time. */ template struct JacobiFraction { T c0; // leading constant std::vector beta; // β_0, β_1, β_2, ... std::vector alpha2; // α_1², α_2², ... (one fewer than β, or the same count) bool terminated; int order; }; namespace detail_qd { /** * @brief Build the qd table from Taylor coefficients via the Rutishauser qd-algorithm * * From input c_0, c_1, ..., c_{2K} (= 2K+1 values), compute up to depth K: * q_k^(0) for k=1..K * e_k^(0) for k=1..K-1 * * If degeneracy (q_k or e_k being 0) is detected, terminate at that point with termination=true. * * Return form: a pair (q_levels, e_levels), both vectors of length K, but elements after * termination are undefined. The actual length is conveyed via last_valid. */ template inline void runQdAlgorithm( std::span c, int K, std::vector& q0, // output: q_1^(0), q_2^(0), ..., q_K^(0) std::vector& e0, // output: e_1^(0), e_2^(0), ..., e_{K-1}^(0) bool& terminated) { terminated = false; q0.clear(); e0.clear(); if (K < 1) return; if (c[0] == T(0)) { // If the leading term is 0, q_1 = c_1 / c_0 cannot be defined terminated = true; return; } // For row n, q_1^(n) = c_{n+1}/c_n, e_0^(n) = 0. // We need q_1^(n) for n = 0..(2K-1); e_0 is not needed (always 0). const int Nrows = 2 * K; if (static_cast(c.size()) < Nrows + 1) { // Shrink to the largest K computable with the given coefficients K = static_cast(c.size() - 1) / 2; if (K < 1) { terminated = true; return; } } // q[k][n] = q_{k+1}^(n) (1-indexed → 0-indexed shift) // Dynamic programming: for column k = 1..K, keep rows n = 0..(2K - 2k + 1) // q_1^(n) is n = 0..2K-1 // q_2^(n) is n = 0..2K-3 // ... // q_K^(n) is n = 0..1 // e_k^(n) is n = 0..(2K - 2k) // For simplicity, instead of managing two rows at a time, use a 2D vector: std::vector> q(K), e(K); // q[k-1] = the q_k^(n) column, e[k-1] = the e_k^(n) column // k = 1: q_1^(n) = c_{n+1} / c_n int rows1 = std::min(2 * K, static_cast(c.size()) - 1); q[0].resize(rows1); for (int n = 0; n < rows1; ++n) { if (c[n] == T(0)) { terminated = true; q[0].resize(n); break; } q[0][n] = c[n + 1] / c[n]; } if (q[0].empty()) { terminated = true; return; } q0.push_back(q[0][0]); // Iterate: k = 2..K for (int k = 2; k <= K; ++k) { // e_{k-1}^(n) = q_{k-1}^(n+1) - q_{k-1}^(n) + e_{k-2}^(n+1) // (when k-2 = 0, e_0^(n) = 0) const int prev_q_size = static_cast(q[k - 2].size()); const int e_size = prev_q_size - 1; if (e_size < 1) { terminated = true; break; } e[k - 2].resize(e_size); for (int n = 0; n < e_size; ++n) { T prev_e = (k - 2 >= 1 && n + 1 < static_cast(e[k - 3].size())) ? e[k - 3][n + 1] : T(0); e[k - 2][n] = q[k - 2][n + 1] - q[k - 2][n] + prev_e; } if (k - 2 == 0 || k == 2) { // output e_1^(0) } e0.push_back(e[k - 2][0]); // Termination test: e_{k-1}^(0) == 0 if (e[k - 2][0] == T(0)) { terminated = true; break; } // q_k^(n) = q_{k-1}^(n+1) · e_{k-1}^(n+1) / e_{k-1}^(n) const int q_size = e_size - 1; if (q_size < 1) { terminated = true; break; } q[k - 1].resize(q_size); for (int n = 0; n < q_size; ++n) { if (e[k - 2][n] == T(0)) { terminated = true; q[k - 1].resize(n); break; } q[k - 1][n] = q[k - 2][n + 1] * e[k - 2][n + 1] / e[k - 2][n]; } if (q[k - 1].empty()) { terminated = true; break; } q0.push_back(q[k - 1][0]); // q_k^(0) == 0 also terminates (Padé degeneracy) if (q[k - 1][0] == T(0)) { terminated = true; break; } } } } // namespace detail_qd /** * @brief Convert a Taylor series to a Stieltjes continued fraction * * f(z) = c_0 + c_1 z + c_2 z² + ... * = c_0 / (1 + a_1 z / (1 + a_2 z / ...)) * * @param taylor Taylor coefficients c_0, c_1, ..., c_L * @param kmax maximum number of a to generate (default: automatic based on input) * * @return StieltjesFraction { b0=c_0, a=[a_1, a_2, ...], terminated, order } * * Truncate when degeneracy (e_k or q_k being 0) is detected, terminated=true. * * Correspondence between S-fraction coefficients and the qd table: * a_1 = -q_1^(0), a_2 = -e_1^(0), a_3 = -q_2^(0), a_4 = -e_2^(0), ... */ template [[nodiscard]] StieltjesFraction taylorToSFraction(std::span taylor, int kmax = -1) { if (taylor.empty()) throw std::invalid_argument("taylorToSFraction: empty taylor"); const int L = static_cast(taylor.size()) - 1; if (kmax < 0) kmax = L; // Coefficients needed by qd: 2K gives q_K^(0), 2K-1 gives e_{K-1}^(0) int K = std::min(kmax / 2 + 1, (L + 1) / 2); // round conservatively if (K < 1) K = 1; StieltjesFraction sf; sf.b0 = taylor[0]; sf.terminated = false; sf.order = L; if (taylor[0] == T(0)) { // Leading 0 → b0=0, empty continued fraction (a shift etc. is expected at the caller level) sf.terminated = true; return sf; } std::vector q0, e0; detail_qd::runQdAlgorithm(taylor, K, q0, e0, sf.terminated); // a_{2k-1} = -q_k^(0), a_{2k} = -e_k^(0) sf.a.reserve(q0.size() + e0.size()); for (std::size_t i = 0; i < std::max(q0.size(), e0.size() + 1); ++i) { if (i < q0.size()) { sf.a.push_back(-q0[i]); if (static_cast(sf.a.size()) >= kmax) break; } if (i < e0.size()) { sf.a.push_back(-e0[i]); if (static_cast(sf.a.size()) >= kmax) break; } } return sf; } template [[nodiscard]] StieltjesFraction taylorToSFraction(const std::vector& taylor, int kmax = -1) { return taylorToSFraction(std::span(taylor), kmax); } /** * @brief Convert a Taylor series to a Jacobi continued fraction * * f(z) = c_0 / (1 - β_0 z - α_1² z² / (1 - β_1 z - α_2² z² / ...)) * * Groups two S-fraction stages at a time. Equivalent to the three-term recurrence of orthogonal polynomials. * * Conversion formulas (from the qd table): * β_0 = q_1^(0) * β_n = q_{n+1}^(0) + e_n^(0) (n ≥ 1) * α_n² = q_n^(0) · e_n^(0) (n ≥ 1) */ template [[nodiscard]] JacobiFraction taylorToJFraction(std::span taylor, int kmax = -1) { if (taylor.empty()) throw std::invalid_argument("taylorToJFraction: empty taylor"); const int L = static_cast(taylor.size()) - 1; if (kmax < 0) kmax = (L + 1) / 2; int K = std::min(kmax + 1, (L + 1) / 2); if (K < 1) K = 1; JacobiFraction jf; jf.c0 = taylor[0]; jf.terminated = false; jf.order = L; if (taylor[0] == T(0)) { jf.terminated = true; return jf; } std::vector q0, e0; detail_qd::runQdAlgorithm(taylor, K, q0, e0, jf.terminated); // β_0 = q_1^(0) if (q0.empty()) return jf; jf.beta.push_back(q0[0]); // β_n = q_{n+1}^(0) + e_n^(0), α_n² = q_n^(0) · e_n^(0) for (std::size_t n = 0; n < e0.size(); ++n) { // α_{n+1}² = q_{n+1}^(0) · e_{n+1}^(0)? Unify the convention: with α_n² (n ≥ 1), // the definition is α_n² = q_n^(0) · e_n^(0), i.e. it starts at α_1². T alpha_sq = q0[n] * e0[n]; jf.alpha2.push_back(alpha_sq); if (n + 1 < q0.size()) { jf.beta.push_back(q0[n + 1] + e0[n]); } if (static_cast(jf.beta.size()) > kmax) break; } return jf; } template [[nodiscard]] JacobiFraction taylorToJFraction(const std::vector& taylor, int kmax = -1) { return taylorToJFraction(std::span(taylor), kmax); } /** * @brief Evaluate a Stieltjes continued fraction (bottom-up) * * f(z) = b0 / (1 + a_1 z / (1 + a_2 z / (1 + a_K z))) */ template [[nodiscard]] T evalSFraction(const StieltjesFraction& sf, T x) { if (sf.a.empty()) return sf.b0; T acc = T(1) + sf.a.back() * x; for (int i = static_cast(sf.a.size()) - 2; i >= 0; --i) { acc = T(1) + sf.a[i] * x / acc; } return sf.b0 / acc; } /** * @brief Evaluate a Jacobi continued fraction (bottom-up) * * f(z) = c0 / (1 - β_0 z - α_1² z² / (1 - β_1 z - α_2² z² / (1 - β_K z))) * * The α_n² array is one shorter than β (the standard J-fraction form). If the lengths are equal, * use up to the trailing α² (accounting for truncation). */ template [[nodiscard]] T evalJFraction(const JacobiFraction& jf, T x) { if (jf.beta.empty()) return jf.c0; const int K = static_cast(jf.beta.size()); T acc = T(1) - jf.beta[K - 1] * x; for (int i = K - 2; i >= 0; --i) { T num = (i + 1 <= static_cast(jf.alpha2.size())) ? jf.alpha2[i] : T(0); acc = T(1) - jf.beta[i] * x - num * x * x / acc; } return jf.c0 / acc; } // ===================================================================== // Multipoint Padé / rational interpolation (Cauchy interpolation) // ===================================================================== /** * @brief Result of rational interpolation * * A generalization of standard Padé (fitting Taylor coefficients at one point) to multipoint * (fitting values at distinct x_i). Also called Cauchy / Newton-Padé / multipoint Padé. * * Returns the [M/N] rational function p(x)/q(x) satisfying f(x_i) = y_i (M + N + 1 = number of data points). * When q(x_i) = 0 (a pole), unless p(x_i) is simultaneously 0 (zero-pole cancellation), valid=false. */ template struct RationalInterpResult { std::vector numerator; // [p0, p1, ..., pM] std::vector denominator; // [1, q1, ..., qN] (normalized to q0 = 1) bool valid; }; /** * @brief Multipoint Padé / rational interpolation — build a rational function from values y_i at distinct x_i * * Linear system: for each i (i = 0..M+N) * p(x_i) - y_i · q(x_i) = 0 * ⟺ Σ_{k=0..M} p_k x_i^k - Σ_{k=1..N} y_i q_k x_i^k = y_i * Solve the (M+N+1)-variable system. * * @param x data points (M+N+1, distinct) * @param y function values (same size as x) * @param M numerator degree * @param N denominator degree * * Note: if M + N + 1 ≠ x.size(), throws invalid_argument. * If the x_i contain duplicates the linear system is ill-conditioned (symbolically it requires a Hermite type). * * Relation to standard padeApprox: with all x_i = 0 (in theory) it degenerates to the single-point version, but * this routine assumes distinct x_i (if you need the x_i = 0 Taylor-coefficient version, use padeApprox). * * Reference: Stoer & Bulirsch "Introduction to Numerical Analysis" §2.2.4 (rational interp.) */ template [[nodiscard]] RationalInterpResult rationalInterpolation( const std::vector& x, const std::vector& y, std::size_t M, std::size_t N) { using SizeT = typename Matrix::size_type; if (x.size() != y.size()) throw std::invalid_argument("rationalInterpolation: x and y must have same size"); if (x.size() != M + N + 1) throw std::invalid_argument("rationalInterpolation: need exactly M+N+1 data points"); const std::size_t L = M + N; // Build the linear system // Variable order: p_0, p_1, ..., p_M, q_1, q_2, ..., q_N // Row i: Σ_{k=0..M} p_k x_i^k + Σ_{k=1..N} (-y_i x_i^k) q_k = y_i Matrix A(static_cast(L + 1), static_cast(L + 1)); Vector b(static_cast(L + 1)); for (std::size_t i = 0; i <= L; ++i) { T xp = T(1); for (std::size_t k = 0; k <= M; ++k) { A(static_cast(i), static_cast(k)) = xp; xp = xp * x[i]; } T xq = x[i]; // x_i^1 for (std::size_t k = 0; k < N; ++k) { A(static_cast(i), static_cast(M + 1 + k)) = -y[i] * xq; xq = xq * x[i]; } b[static_cast(i)] = y[i]; } Vector sol(static_cast(0)); try { sol = algorithms::solve(A, b, algorithms::SolverType::LU); } catch (...) { return {{}, {}, false}; } RationalInterpResult r; r.numerator.resize(M + 1); for (std::size_t k = 0; k <= M; ++k) r.numerator[k] = sol[static_cast(k)]; r.denominator.resize(N + 1); r.denominator[0] = T(1); for (std::size_t k = 0; k < N; ++k) r.denominator[k + 1] = sol[static_cast(M + 1 + k)]; // Verification: f(x_i) = y_i (within tolerance) at every data point // In floating point this is not exact, so verify externally. r.valid = true; return r; } /** * @brief Evaluate a rational interpolation result (Horner's method) */ template [[nodiscard]] T evalRational(const RationalInterpResult& r, T x) { T num = r.numerator.back(); for (std::size_t i = r.numerator.size() - 1; i > 0; --i) num = num * x + r.numerator[i - 1]; T den = r.denominator.back(); for (std::size_t i = r.denominator.size() - 1; i > 0; --i) den = den * x + r.denominator[i - 1]; return num / den; } // ===================================================================== // Matrix-valued Padé — matrix exponential e^A (Scaling-and-Squaring + diagonal Padé) // ===================================================================== /** * @brief Compute the matrix exponential e^A. * * Thin wrapper over the library's production matrix exponential * algorithms::expm (scaling-and-squaring with a [13/13] Pade core, plus a * Schur-Parlett path for higher precision; Higham 2005). The former stand-alone * Pade implementation that lived here was removed in favour of a single, * better-tested source of truth. * * @param A input matrix (square) * @param m retained only for source compatibility; the production routine * selects the Pade degree internally, so this argument is ignored. * @return e^A */ template [[nodiscard]] Matrix matrixExpPade(const Matrix& A, [[maybe_unused]] int m = 6) { if (A.rows() != A.cols()) throw std::invalid_argument("matrixExpPade: matrix must be square"); return algorithms::expm(A); } // ===================================================================== // Least-squares polynomial approximation // ===================================================================== /** * @brief Result of a least-squares polynomial approximation */ template struct PolynomialFitResult { std::vector coefficients; // polynomial coefficients [a0, a1, ..., aN] (a0 + a1*x + ... + aN*x^N) T residual; // residual sum of squares }; /** * @brief Least-squares polynomial approximation * * Fit a degree-`degree` polynomial p(x) = a0 + a1*x + ... + a_d*x^d to the data points (x_i, y_i) * by least squares. * * @param x array of independent variables * @param y array of dependent variables (same size as x) * @param degree polynomial degree (≥ 0) * @return PolynomialFitResult{coefficients, residual} * * @note Improvements: * - mean removal applied for numerical stability (improves the condition number) * - solve the normal equations with algorithms::solve(LU) * - returns the residual sum of squares */ template [[nodiscard]] PolynomialFitResult polynomialFit( std::span x, std::span y, std::size_t degree) { if (x.size() != y.size()) { throw std::invalid_argument("polynomialFit: x and y must have the same size"); } if (x.size() < degree + 1) { throw std::invalid_argument("polynomialFit: need at least degree+1 data points"); } const std::size_t n = x.size(); const std::size_t N = degree + 1; // number of coefficients // Subtract the mean for numerical stability T mu_x = T(0), mu_y = T(0); for (std::size_t i = 0; i < n; ++i) { mu_x += x[i]; mu_y += y[i]; } mu_x /= static_cast(n); mu_y /= static_cast(n); std::vector xs(n), ys(n); for (std::size_t i = 0; i < n; ++i) { xs[i] = x[i] - mu_x; ys[i] = y[i] - mu_y; } // Build the normal equations A*c = b // A(i,j) = Σ_k xs[k]^(i+j), b(i) = Σ_k xs[k]^i * ys[k] Matrix A(static_cast::size_type>(N), static_cast::size_type>(N)); Vector b(static_cast::size_type>(N)); // Precompute the powers of xs[k] (up to degree 2*degree) std::vector x_pow_sum(2 * degree + 1, T(0)); for (std::size_t k = 0; k < n; ++k) { T xp = T(1); for (std::size_t p = 0; p <= 2 * degree; ++p) { x_pow_sum[p] += xp; xp *= xs[k]; } } for (std::size_t i = 0; i < N; ++i) { for (std::size_t j = 0; j < N; ++j) { A(static_cast::size_type>(i), static_cast::size_type>(j)) = x_pow_sum[i + j]; } T sum = T(0); for (std::size_t k = 0; k < n; ++k) { T xp = T(1); for (std::size_t p = 0; p < i; ++p) xp *= xs[k]; sum += xp * ys[k]; } b[static_cast::size_type>(i)] = sum; } // Solve (coefficients in the shifted space) Vector c_shifted = algorithms::solve(A, b, algorithms::SolverType::LU); // Convert the shifted-space polynomial p_s(u) = Σ c_k * u^k, u = x - mu_x // to the original space p(x) = Σ a_j * x^j // p(x) = p_s(x - mu_x) + mu_y // (x - mu_x)^k = Σ_{j=0}^{k} C(k,j) * x^j * (-mu_x)^(k-j) // Compute the original coefficients by binomial expansion std::vector coeffs(N, T(0)); for (std::size_t k = 0; k < N; ++k) { T ck = c_shifted[static_cast::size_type>(k)]; // Expansion of (x - mu_x)^k T binom = T(1); // C(k, j) T neg_mu_pow = T(1); // (-mu_x)^(k-j) — start at j=0 and update // At j=0: C(k,0)*x^0*(-mu_x)^k // First compute (-mu_x)^k T neg_mu_k = T(1); for (std::size_t p = 0; p < k; ++p) neg_mu_k *= (-mu_x); for (std::size_t j = 0; j <= k; ++j) { // C(k,j) * (-mu_x)^{k-j} coeffs[j] += ck * binom * neg_mu_k; // Update for the next j: C(k,j+1) = C(k,j) * (k-j)/(j+1) // (-mu_x)^{k-j-1} = (-mu_x)^{k-j} / (-mu_x) if (j < k) { binom *= static_cast(k - j) / static_cast(j + 1); if (mu_x != T(0)) { neg_mu_k /= (-mu_x); } else { neg_mu_k = (k - j - 1 == 0) ? T(1) : T(0); } } } } coeffs[0] += mu_y; // add mu_y to the constant term // residual sum of squares T residual = T(0); for (std::size_t i = 0; i < n; ++i) { // Evaluate p(x[i]) by Horner's method T val = coeffs[degree]; for (std::size_t j = degree; j > 0; --j) { val = val * x[i] + coeffs[j - 1]; } T diff = y[i] - val; residual += diff * diff; } return {coeffs, residual}; } template [[nodiscard]] PolynomialFitResult polynomialFit( const std::vector& x, const std::vector& y, std::size_t degree) { return polynomialFit(std::span(x), std::span(y), degree); } } // namespace sangi #endif // SANGI_APPROXIMATION_HPP