#![cfg_attr(
not(feature = "random"),
doc = r"
[`Distribution::sample()`]:
https://docs.rs/oefpil/latest/oefpil/struct.Distribution.html#method.sample
"
)]
use derive_more::{Display, Error, From};
pub use oefpil_sys::{Criterion, Verbosity};
use oefpil_sys::{
Evaluate, Mode, dpotrf,
libc::{FILE, fclose, fflush, fopen},
oefpil, oefpil_tcm_blockdiag_set_tile_diag, oefpil_tcm_blockdiag_set_tile_full,
oefpil_tcm_blockdiag_set_tile_half, oefpil_tcm_diag_set_tile_diag,
oefpil_tcm_diags_set_tile_diag, oefpil_tcm_full_set_tile_diag, oefpil_tcm_full_set_tile_full,
oefpil_tcm_full_set_tile_half, stderr_file, stdout_file,
};
use std::{
ffi::{CString, c_int, c_void},
fs::OpenOptions,
io::{self, BufRead, BufReader, ErrorKind},
num::TryFromIntError,
path::Path,
ptr,
slice::{from_raw_parts, from_raw_parts_mut},
};
#[derive(Debug, Clone, Copy)]
pub struct Algorithm {
pub criterion: Criterion,
pub tolerance: f64,
pub iteration_limit: usize,
pub verbosity: Verbosity,
}
impl Default for Algorithm {
fn default() -> Self {
Self {
criterion: Criterion::default(),
tolerance: f64::EPSILON.powf(2.0 / 3.0),
iteration_limit: 100,
verbosity: Verbosity::default(),
}
}
}
impl Algorithm {
pub fn fit(
&self,
model: Model,
variable: &mut Variable,
parameter: &mut Parameter,
logfile: Logfile,
) -> Result<Report, OefpilError> {
model.validate()?;
variable.validate(model)?;
parameter.validate(model)?;
let x_variables = model.dfdx.len();
let samples = variable.covariance.samples;
let parameters = parameter.mean.len();
let x_fields = samples * x_variables;
let mut x_mean;
let mut y_mean;
let x_mean = if variable.mean.is_empty() {
x_mean = variable.sample[..x_fields].to_vec();
x_mean.as_mut_ptr()
} else {
variable.mean.as_mut_ptr()
};
let y_mean = if variable.mean.is_empty() {
y_mean = variable.sample[x_fields..].to_vec();
y_mean.as_mut_ptr()
} else {
variable.mean[x_fields..].as_mut_ptr()
};
let evaluate: Evaluate = Some(evaluate);
let mut data = Data {
model,
x: &mut vec![0.0; x_variables],
};
let mut info = 0;
let mut iterations = 0;
let mut report = Report {
degrees_of_freedom: samples
.checked_sub(parameters)
.filter(|&dof| dof >= usize::from(!variable.covariance.unknown_scale))
.ok_or(VariableError::InsufficientDegreesOfFreedom)?,
..Report::default()
};
unsafe {
let file = logfile.open()?;
oefpil(
evaluate,
ptr::from_mut(&mut data).cast(),
model.implicit.into(),
parameters.try_into()?,
parameter.mean.as_mut_ptr(),
parameter.covariance.as_mut_ptr(),
samples.try_into()?,
x_variables.try_into()?,
variable.sample[..x_fields].as_ptr(),
variable.sample[x_fields..].as_ptr(),
x_mean,
y_mean,
variable.covariance.fields.as_ptr(),
variable.covariance.mode as c_int,
variable.covariance.tiles.as_ptr(),
self.iteration_limit.try_into()?,
self.tolerance,
self.verbosity as i32,
file,
ptr::from_mut(&mut report.chi_squared),
self.criterion as i32,
&raw mut info,
&raw mut iterations,
&raw mut report.chi_squared_reduced,
variable.covariance.unknown_scale,
ptr::null_mut(),
);
let log = if matches!(logfile, Logfile::Custom(_path)) {
fclose(file)
} else {
fflush(file)
};
if log != 0 {
Err(io::Error::last_os_error())?;
}
};
report.iterations = iterations.try_into()?;
if !parameter.deviation.is_empty() {
for row_column in 0..parameters {
let diagonal = row_column * parameters + row_column;
parameter.deviation[row_column] = parameter.covariance[diagonal].sqrt();
}
}
if !parameter.correlation.is_empty() {
for row in 0..parameters {
for column in 0..parameters {
let field = row * parameters + column;
parameter.correlation[field] = parameter.covariance[field]
/ (parameter.deviation[row] * parameter.deviation[column]);
}
}
}
match info {
1 => Ok(report),
2 => Err(AlgorithmError::IterationLimit.into()),
3 => Err(AlgorithmError::NumericalError.into()),
_ => unreachable!("unknown error"),
}
}
}
struct Data<'a> {
model: Model<'a>,
x: &'a mut [f64],
}
#[allow(clippy::similar_names)]
unsafe extern "C" fn evaluate(
data: *mut c_void,
samples: c_int,
x_sample: *const f64,
parameter: *const f64,
fx: *mut f64,
dfdx: *mut f64,
dfdp: *mut f64,
) {
let Data { model, x } = unsafe { &mut *data.cast::<Data>() };
let samples: usize = samples.try_into().unwrap();
let x_variables = model.dfdx.len();
let parameters = model.dfdp.len();
let x_sample = unsafe { from_raw_parts(x_sample, samples * x_variables) };
let p = unsafe { from_raw_parts(parameter, parameters) };
let fx = unsafe { from_raw_parts_mut(fx, samples) };
let dfdx = unsafe { from_raw_parts_mut(dfdx, samples * x_variables) };
let dfdp = unsafe { from_raw_parts_mut(dfdp, samples * parameters) };
for sample in 0..samples {
for x_variable in 0..x_variables {
x[x_variable] = x_sample[x_variable * samples + sample];
}
fx[sample] = (model.fx)(x, p);
for x_variable in 0..x_variables {
dfdx[x_variable * samples + sample] = (model.dfdx[x_variable])(x, p);
}
for parameter in 0..parameters {
dfdp[parameter * samples + sample] = (model.dfdp[parameter])(x, p);
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub enum Logfile<'a> {
#[default]
StdOut,
StdErr,
Custom(&'a Path),
}
impl<'a> Logfile<'a> {
fn open(&'a self) -> Result<*mut FILE, io::Error> {
unsafe {
match self {
Self::StdOut => Ok(stdout_file()),
Self::StdErr => Ok(stderr_file()),
Self::Custom(path) => {
let path = CString::new(path.as_os_str().as_encoded_bytes()).unwrap();
let file = fopen(path.as_c_str().as_ptr(), c"w".as_ptr());
if file.is_null() {
Err(io::Error::last_os_error())
} else {
Ok(file)
}
}
}
}
}
}
#[derive(Debug, Clone, Copy)]
#[allow(clippy::similar_names)]
pub struct Model<'a> {
pub fx: Expression,
pub dfdx: &'a [Expression],
pub dfdp: &'a [Expression],
pub implicit: bool,
}
impl Model<'_> {
#[must_use]
#[inline]
pub fn variables(&self) -> usize {
self.dfdx.len() + usize::from(!self.implicit)
}
pub fn validate(&self) -> Result<(), ModelError> {
if self.dfdx.is_empty() {
return Err(ModelError::EmptyVariableDerivative);
}
if self.dfdp.is_empty() {
return Err(ModelError::EmptyParameterDerivative);
}
Ok(())
}
}
pub type Expression = fn(x: &[f64], p: &[f64]) -> f64;
#[derive(Debug)]
#[cfg(feature = "random")]
pub struct Distribution<'a> {
pub sample: &'a mut [f64],
pub mean: &'a [f64],
pub covariance: &'a Covariance,
}
#[cfg(feature = "random")]
impl Distribution<'_> {
pub fn sample(&mut self, random: &mut dyn rand::RngCore) -> Result<(), OefpilError> {
self.validate()?;
let length = self.sample.len().try_into()?;
self.sample
.iter_mut()
.zip(rand::Rng::sample_iter(random, rand_distr::StandardNormal))
.for_each(|(sample, random): (&mut f64, f64)| *sample = random);
unsafe {
oefpil_sys::dtrmv(
c"L".as_ptr(),
c"N".as_ptr(),
c"N".as_ptr(),
length,
self.covariance.decomposition.as_ptr(),
length,
self.sample.as_mut_ptr(),
1,
);
};
self.sample
.iter_mut()
.zip(self.mean)
.for_each(|(sample, mean)| {
*sample += mean;
});
Ok(())
}
pub fn validate(&self) -> Result<(), DistributionError> {
if self.covariance.decomposition.is_empty() {
return Err(DistributionError::MissingDecomposition);
}
let fields = self.covariance.order();
if self.sample.len() != fields {
return Err(DistributionError::MismatchingSample);
}
if self.mean.len() != fields {
return Err(DistributionError::MismatchingMean);
}
Ok(())
}
}
#[derive(Debug)]
pub struct Variable<'a> {
pub sample: &'a [f64],
pub mean: &'a mut [f64],
pub covariance: &'a Covariance,
}
impl Variable<'_> {
pub fn validate(&self, model: Model) -> Result<(), VariableError> {
if self.covariance.variables != model.variables() {
return Err(VariableError::MismatchingCovariance);
}
let fields = self.covariance.samples * self.covariance.variables;
if self.sample.len() != fields {
return Err(VariableError::MismatchingSample);
}
if !self.mean.is_empty() && self.mean.len() != fields {
return Err(VariableError::MismatchingMean);
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct Covariance {
mode: Mode,
tiles: Vec<c_int>,
fields: Vec<f64>,
decomposition: Vec<f64>,
samples: usize,
variables: usize,
unknown_scale: bool,
}
impl Covariance {
#[must_use]
pub fn new_diagonal(samples: usize, variables: usize) -> Self {
assert!(samples > 0);
assert!(variables > 0);
let tiles = vec![Mode::None as c_int; variables];
let fields = vec![0.0; samples * variables];
Self {
mode: Mode::Diagonal,
tiles,
fields,
decomposition: Vec::new(),
samples,
variables,
unknown_scale: false,
}
}
#[must_use]
pub fn new_block_diagonal(samples: usize, variables: usize) -> Self {
assert!(samples > 0);
assert!(variables > 0);
let tiles = vec![Mode::None as c_int; variables];
let fields = vec![0.0; samples.pow(2) * variables];
Self {
mode: Mode::BlockDiagonal,
tiles,
fields,
decomposition: Vec::new(),
samples,
variables,
unknown_scale: false,
}
}
#[must_use]
pub fn new_diagonals(samples: usize, variables: usize) -> Self {
assert!(samples > 0);
assert!(variables > 0);
assert_eq!(
variables, 2,
"currently only implemented for `variables == 2`"
);
let tiles = vec![Mode::None as c_int; variables.pow(2)];
let fields = vec![0.0; samples * variables.pow(2)];
Self {
mode: Mode::Diagonals,
tiles,
fields,
decomposition: Vec::new(),
samples,
variables,
unknown_scale: false,
}
}
#[must_use]
pub fn new_full(samples: usize, variables: usize) -> Self {
assert!(samples > 0);
assert!(variables > 0);
assert_eq!(
variables, 2,
"currently only implemented for `variables == 2`"
);
let tiles = vec![Mode::None as c_int; variables.pow(2)];
let fields = vec![0.0; (samples * variables).pow(2)];
Self {
mode: Mode::Full,
tiles,
fields,
decomposition: Vec::new(),
samples,
variables,
unknown_scale: false,
}
}
pub fn set_tile(
&mut self,
row: usize,
column: usize,
fields: &[f64],
) -> Result<(), OefpilError> {
self.decomposition.clear();
unsafe {
if !(row < self.variables && column < self.variables) {
return Err(CovarianceError::OutOfBoundTileIndex.into());
}
let samples = i32::try_from(self.samples)?;
let variables = i32::try_from(self.variables)?;
let row = i32::try_from(row)?;
let column = i32::try_from(column)?;
match self.mode {
Mode::None => unreachable!("invalid mode"),
Mode::Diagonal => {
if row != column {
return Err(CovarianceError::NonDiagonalTileIndex.into());
}
if fields.len() != self.samples {
return Err(CovarianceError::MismatchingTile.into());
}
oefpil_tcm_diag_set_tile_diag(
samples,
variables,
self.fields.as_mut_ptr(),
self.tiles.as_mut_ptr(),
row,
fields.as_ptr(),
);
}
Mode::BlockDiagonal => {
if row != column {
return Err(CovarianceError::NonDiagonalTileIndex.into());
}
let oefpil_tcm_blockdiag_set_tile = if fields.len() == self.samples {
oefpil_tcm_blockdiag_set_tile_diag
} else if fields.len() == self.samples * (self.samples + 1) / 2 {
oefpil_tcm_blockdiag_set_tile_half
} else if fields.len() == self.samples.pow(2) {
if !self.is_symmetric(fields) {
return Err(CovarianceError::AsymmetricTile.into());
}
oefpil_tcm_blockdiag_set_tile_full
} else {
return Err(CovarianceError::MismatchingTile.into());
};
oefpil_tcm_blockdiag_set_tile(
samples,
variables,
self.fields.as_mut_ptr(),
self.tiles.as_mut_ptr(),
row,
fields.as_ptr(),
);
}
Mode::Diagonals => {
if fields.len() != self.samples {
return Err(CovarianceError::MismatchingTile.into());
}
oefpil_tcm_diags_set_tile_diag(
samples,
variables,
self.fields.as_mut_ptr(),
self.tiles.as_mut_ptr(),
row,
column,
fields.as_ptr(),
);
}
Mode::Full => {
let oefpil_tcm_full_set_tile = if fields.len() == self.samples {
oefpil_tcm_full_set_tile_diag
} else if fields.len() == self.samples * (self.samples + 1) / 2 {
oefpil_tcm_full_set_tile_half
} else if fields.len() == self.samples.pow(2) {
if row == column && !self.is_symmetric(fields) {
return Err(CovarianceError::AsymmetricTile.into());
}
oefpil_tcm_full_set_tile_full
} else {
return Err(CovarianceError::MismatchingTile.into());
};
oefpil_tcm_full_set_tile(
samples,
variables,
self.fields.as_mut_ptr(),
self.tiles.as_mut_ptr(),
row,
column,
fields.as_ptr(),
);
}
}
}
Ok(())
}
pub fn with_tile(
mut self,
row: usize,
column: usize,
fields: &[f64],
) -> Result<Self, OefpilError> {
self.set_tile(row, column, fields).map(|()| self)
}
#[must_use]
#[inline]
pub const fn with_unknown_scale(mut self) -> Self {
self.unknown_scale = true;
self
}
#[cfg_attr(
not(feature = "random"),
doc = r"
[`Distribution::sample()`]:
https://docs.rs/oefpil/latest/oefpil/struct.Distribution.html#method.sample
"
)]
pub fn with_decomposition(mut self) -> Result<Self, OefpilError> {
self.decomposition = self.to_full();
let order = self.order().try_into()?;
let mut info = 0;
unsafe {
dpotrf(
c"L".as_ptr(),
order,
self.decomposition.as_mut_ptr(),
order,
&raw mut info,
);
};
match info {
0 => Ok(self),
_ => Err(AlgorithmError::NumericalError.into()),
}
}
#[must_use]
pub fn to_full(&self) -> Vec<f64> {
let order = self.order();
let mut fields = vec![0f64; order * order];
match self.mode {
Mode::None => unreachable!("invalid mode"),
Mode::Diagonal => {
for row_column in 0..self.fields.len() {
fields[row_column * order + row_column] = self.fields[row_column];
}
}
Mode::BlockDiagonal => {
for variable in 0..self.variables {
let target = variable * (order + 1) * self.samples;
let source = variable * self.samples.pow(2);
for row in 0..self.samples {
for column in 0..self.samples {
let target = target + order * row + column;
let source = source + row * self.samples + column;
fields[target] = self.fields[source];
}
}
}
}
Mode::Diagonals => {
for row in 0..self.variables {
let target = row * order * self.samples;
for column in 0..self.variables {
let target = target + column * self.samples;
let source = (row * self.variables + column) * self.samples;
for sample in 0..self.samples {
let target = target + sample * order + sample;
let source = source + sample;
fields[target] = self.fields[source];
}
}
}
}
Mode::Full => fields.clone_from(&self.fields),
}
fields
}
#[must_use]
#[inline]
pub const fn samples(&self) -> usize {
self.samples
}
#[must_use]
#[inline]
pub const fn variables(&self) -> usize {
self.variables
}
#[must_use]
#[inline]
pub const fn order(&self) -> usize {
self.samples * self.variables
}
#[must_use]
fn is_symmetric(&self, fields: &[f64]) -> bool {
for row in 0..self.samples {
for column in 0..self.samples {
#[allow(clippy::float_cmp)]
if fields[row * self.samples + column] != fields[column * self.samples + row] {
return false;
}
}
}
true
}
}
#[allow(clippy::too_long_first_doc_paragraph)]
#[derive(Debug, Default)]
pub struct Parameter<'a> {
pub mean: &'a mut [f64],
pub deviation: &'a mut [f64],
pub covariance: &'a mut [f64],
pub correlation: &'a mut [f64],
}
impl Parameter<'_> {
pub fn validate(&self, model: Model) -> Result<(), ParameterError> {
if self.mean.len() != model.dfdp.len() {
return Err(ParameterError::MismatchingMean);
}
if self.covariance.len() != self.mean.len().pow(2) {
return Err(ParameterError::MismatchingCovariance);
}
if !self.deviation.is_empty() && self.deviation.len() != self.mean.len() {
return Err(ParameterError::MismatchingDeviation);
}
if !self.correlation.is_empty() && self.correlation.len() != self.covariance.len() {
return Err(ParameterError::MismatchingCorrelation);
}
if !self.correlation.is_empty() && self.deviation.is_empty() {
return Err(ParameterError::CorrelationRequiresDeviation);
}
Ok(())
}
}
#[derive(Debug, Clone, Copy)]
pub struct Report {
pub chi_squared: f64,
pub chi_squared_reduced: f64,
pub degrees_of_freedom: usize,
pub iterations: usize,
}
impl Report {
pub fn chi_squared_p_value(&self) -> Result<f64, OefpilError> {
let mut p = f64::NAN;
let mut q = f64::NAN;
let mut error = 0;
unsafe {
oefpil_sys::dcdchi(
self.chi_squared,
f64::from(i32::try_from(self.degrees_of_freedom)?),
&raw mut p,
&raw mut q,
&raw mut error,
);
}
match error {
0 => Ok(q),
_ => Err(AlgorithmError::NumericalError.into()),
}
}
}
impl Default for Report {
fn default() -> Self {
Self {
chi_squared: f64::NAN,
chi_squared_reduced: f64::NAN,
degrees_of_freedom: 0,
iterations: 0,
}
}
}
#[derive(Debug, Display, Error, From)]
#[non_exhaustive]
pub enum OefpilError {
#[display("I/O error")]
Io(#[error(source)] io::Error),
#[display("conversation error")]
TryFromInt(#[error(source)] TryFromIntError),
#[display("invalid model")]
Model(#[error(source)] ModelError),
#[display("invalid variable")]
Variable(#[error(source)] VariableError),
#[display("invalid distribution")]
#[cfg(feature = "random")]
Distribution(#[error(source)] DistributionError),
#[display("invalid covariance")]
Covariance(#[error(source)] CovarianceError),
#[display("invalid parameter")]
Parameter(#[error(source)] ParameterError),
#[display("algorithm error")]
Algorithm(#[error(source)] AlgorithmError),
}
#[derive(Debug, Display, Error, PartialEq, Eq, Clone, Copy)]
#[non_exhaustive]
pub enum ModelError {
#[display("empty variable derivative vector")]
EmptyVariableDerivative,
#[display("empty parameter derivative vector")]
EmptyParameterDerivative,
}
#[derive(Debug, Display, Error, PartialEq, Eq, Clone, Copy)]
#[non_exhaustive]
pub enum VariableError {
#[display("mismatching length of multivariate sample matrix")]
MismatchingSample,
#[display("mismatching length of multivariate mean matrix")]
MismatchingMean,
#[display("mismatching covariance order")]
MismatchingCovariance,
#[display("insufficient degrees of freedom")]
InsufficientDegreesOfFreedom,
}
#[derive(Debug, Display, Error, PartialEq, Eq, Clone, Copy)]
#[non_exhaustive]
#[cfg(feature = "random")]
pub enum DistributionError {
#[display("mismatching length of multivariate sample matrix")]
MismatchingSample,
#[display("mismatching length of multivariate mean matrix")]
MismatchingMean,
#[display("missing decomposition")]
MissingDecomposition,
}
#[derive(Debug, Display, Error, PartialEq, Eq, Clone, Copy)]
#[non_exhaustive]
pub enum CovarianceError {
#[display("out of bound tile index")]
OutOfBoundTileIndex,
#[display("non-diagonal tile index")]
NonDiagonalTileIndex,
#[display("mismatching tile length")]
MismatchingTile,
#[display("asymmetric tile on diagonal of block-diagonal or full covariance matrix")]
AsymmetricTile,
}
#[derive(Debug, Display, Error, PartialEq, Eq, Clone, Copy)]
#[non_exhaustive]
pub enum ParameterError {
#[display("mismatching length of mean vector")]
MismatchingMean,
#[display("mismatching length of deviation vector")]
MismatchingDeviation,
#[display("mismatching length of covariance matrix")]
MismatchingCovariance,
#[display("mismatching length of correlation matrix")]
MismatchingCorrelation,
#[display("non-empty correlation matrix slice requires non-empty deviation vector slice")]
CorrelationRequiresDeviation,
}
#[derive(Debug, Display, Error, PartialEq, Eq, Clone, Copy)]
#[non_exhaustive]
pub enum AlgorithmError {
#[display("iteration limit reached before satisfying convergence criterion")]
IterationLimit,
#[display("numerical error probably due to infinite values")]
NumericalError,
}
pub fn read_matrix(
path: &Path,
rows: usize,
columns: usize,
transpose: bool,
) -> io::Result<Vec<f64>> {
let file = BufReader::new(OpenOptions::new().read(true).open(path)?);
let mut matrix = vec![0f64; rows * columns];
let mut lines = file.lines();
for row in 0..rows {
let line = lines.next().ok_or_else(|| {
io::Error::new(
ErrorKind::InvalidData,
format!("expected {rows} row(s), found {} row(s)", row + 1),
)
})??;
let mut words = line.split_whitespace();
for column in 0..columns {
let word = words.next().ok_or_else(|| {
io::Error::new(
ErrorKind::InvalidData,
format!(
"expected {columns} column(s), found {} column(s)",
column + 1
),
)
})?;
let field = if transpose {
column * rows + row
} else {
row * rows + column
};
matrix[field] = word
.parse()
.map_err(|err| io::Error::new(ErrorKind::InvalidData, err))?;
}
if words.next().is_some() {
Err(io::Error::new(
ErrorKind::InvalidData,
format!("expected {columns} column(s), found more"),
))?;
}
}
if lines.next().is_some() {
Err(io::Error::new(
ErrorKind::InvalidData,
format!("expected {rows} rows(s), found more"),
))?;
}
Ok(matrix)
}