Current section

Files

Jump to
gleastsq src gleastsq.gleam
Raw

src/gleastsq.gleam

import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/otp/task
import gleam/result
import gleastsq/internal/nx.{type NxTensor}
pub opaque type FitErrors {
NonConverged
WrongParameters(String)
JacobianTaskError
}
fn convert_func_params(
func: fn(Float, List(Float)) -> Float,
) -> fn(NxTensor, NxTensor) -> Float {
fn(x: NxTensor, params: NxTensor) -> Float {
func(nx.to_number(x), nx.to_list_1d(params))
}
}
fn jacobian(
x: NxTensor,
func: fn(NxTensor, NxTensor) -> Float,
params: NxTensor,
epsilon: Float,
) {
let #(n) = nx.shape(params)
let jac_result =
list.range(0, n - 1)
|> list.map(fn(i) {
task.async(fn() { compute_jacobian_col(x, func, params, epsilon, n, i) })
})
|> list.map(task.try_await_forever(_))
|> result.all
case jac_result {
Ok(jac_cols) -> Ok(nx.concatenate(jac_cols, 1))
Error(_) -> Error(JacobianTaskError)
}
}
fn compute_jacobian_col(
x: NxTensor,
func: fn(NxTensor, NxTensor) -> Float,
params: NxTensor,
epsilon: Float,
n: Int,
i: Int,
) -> NxTensor {
let mask = nx.indexed_put(nx.broadcast(0.0, #(n)), nx.tensor([i]), epsilon)
let up_params = nx.add(params, mask)
let down_params = nx.subtract(params, mask)
let up_f = nx.map(x, func(_, up_params))
let down_f = nx.map(x, func(_, down_params))
nx.new_axis(nx.divide(nx.subtract(up_f, down_f), 2.0 *. epsilon), 1)
}
fn do_least_squares(
x: NxTensor,
y: NxTensor,
func: fn(NxTensor, NxTensor) -> Float,
params: NxTensor,
max_iterations: Int,
epsilon: Float,
tolerance: Float,
lambda_reg: Float,
) -> Result(NxTensor, FitErrors) {
let m = nx.shape(params).0
case max_iterations {
0 -> Error(NonConverged)
iterations -> {
let r = x |> nx.map(func(_, params)) |> nx.subtract(y, _)
use j <- result.try(jacobian(x, func, params, epsilon))
let jt = nx.transpose(j)
let lambda_eye = nx.eye(m) |> nx.multiply(lambda_reg)
let h = nx.add(nx.dot(jt, j), lambda_eye)
let g = nx.dot(jt, r)
let delta = nx.solve(h, g)
case nx.to_number(nx.norm(delta)) {
x if x <. tolerance -> Ok(params)
_ ->
do_least_squares(
x,
y,
func,
nx.add(params, delta),
iterations - 1,
epsilon,
tolerance,
lambda_reg,
)
}
}
}
}
fn compare_list_sizes(
a: List(a),
b: List(a),
msg: String,
callback: fn() -> Result(b, FitErrors),
) {
case list.length(a) == list.length(b) {
True -> callback()
False -> Error(WrongParameters(msg))
}
}
/// Compute the least squares fit of a function to a set of data points.
///
/// ## Parameters:
/// - `x`: The X axis of the data points as a list of floats.
/// - `y`: The Y axis of the data points as a list of floats.
/// - `func`: The function to fit to the data points. The function should take a float and a list of floats (the function coefficients) as arguments and return a float.
/// - `initial_params`: The initial guess for the function coefficients.
/// - `max_iterations`: The maximum number of iterations to perform. Default is 100.
/// - `epsilon`: The epsilon value for the numerical derivative. Default is 0.0001.
/// - `tolerance`: The tolerance for the convergence criterion. Default is 0.0001.
/// - `lambda_reg`: The regularization parameter. Default is 0.0001.
///
/// ## Examples
///
/// ```gleam
/// fn parabola(x: Float, params: List(Float)) -> Float {
/// let assert [a, b, c] = params
/// a *. x *. x +. b *. x +. c
/// }
///
/// pub fn main() {
/// let x = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0]
/// let y = list.map(x, fn(x) { x *. x })
/// let initial_guess = [1.0, 1.0, 1.0]
///
/// let assert Ok(result) =
/// gleastsq.least_squares(
/// x,
/// y,
/// parabola,
/// initial_guess,
/// max_iterations: None,
/// epsilon: None,
/// tolerance: None,
/// lambda_reg: None,
/// )
///
/// io.debug(result) // [1.0, 0.0, 0.0] (within numerical error)
/// }
/// ```
pub fn least_squares(
x: List(Float),
y: List(Float),
func: fn(Float, List(Float)) -> Float,
initial_params: List(Float),
max_iterations iterations: Option(Int),
epsilon epsilon: Option(Float),
tolerance tolerance: Option(Float),
lambda_reg lambda_reg: Option(Float),
) -> Result(List(Float), FitErrors) {
use <- compare_list_sizes(x, y, "x and y must have the same length")
let p = nx.tensor(initial_params)
let x = nx.tensor(x)
let y = nx.tensor(y)
let func = convert_func_params(func)
let iter = case iterations {
Some(x) -> x
None -> 100
}
let eps = case epsilon {
Some(x) -> x
None -> 0.0001
}
let tol = case tolerance {
Some(x) -> x
None -> 0.0001
}
let reg = case lambda_reg {
Some(x) -> x
None -> 0.0001
}
use fitted <- result.try(do_least_squares(x, y, func, p, iter, eps, tol, reg))
Ok(fitted |> nx.to_list_1d)
}