use nalgebra::{DMatrix, DVector};
use rand::prelude::*;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use super::aggregation::ComparisonRecord;
use super::evaluator::CandidateId;
use super::uncertainty::FitnessEstimate;
struct FitContext<'a> {
comparisons: &'a [ComparisonRecord],
candidate_ids: &'a [CandidateId],
id_to_index: HashMap<CandidateId, usize>,
n: usize,
}
impl<'a> FitContext<'a> {
fn new(comparisons: &'a [ComparisonRecord], candidate_ids: &'a [CandidateId]) -> Self {
let id_to_index: HashMap<CandidateId, usize> = candidate_ids
.iter()
.enumerate()
.map(|(i, &id)| (id, i))
.collect();
let n = candidate_ids.len();
Self {
comparisons,
candidate_ids,
id_to_index,
n,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum BradleyTerryOptimizer {
NewtonRaphson {
max_iterations: usize,
tolerance: f64,
#[serde(alias = "regularization")]
prior_lambda: f64,
},
MM {
max_iterations: usize,
tolerance: f64,
bootstrap_samples: usize,
},
}
impl Default for BradleyTerryOptimizer {
fn default() -> Self {
Self::NewtonRaphson {
max_iterations: 100,
tolerance: 1e-6, prior_lambda: 0.1,
}
}
}
impl BradleyTerryOptimizer {
pub fn newton_raphson(max_iterations: usize, tolerance: f64, prior_lambda: f64) -> Self {
Self::NewtonRaphson {
max_iterations,
tolerance,
prior_lambda,
}
}
pub fn mm(max_iterations: usize, tolerance: f64, bootstrap_samples: usize) -> Self {
Self::MM {
max_iterations,
tolerance,
bootstrap_samples,
}
}
}
#[derive(Clone, Debug)]
pub struct BradleyTerryResult {
pub strengths: HashMap<CandidateId, f64>,
pub covariance: DMatrix<f64>,
pub id_to_index: HashMap<CandidateId, usize>,
pub log_likelihood: f64,
pub iterations: usize,
pub converged: bool,
pub convergence_metric: f64,
}
impl BradleyTerryResult {
pub fn get_estimate(&self, id: CandidateId) -> Option<FitnessEstimate> {
let strength = *self.strengths.get(&id)?;
let idx = *self.id_to_index.get(&id)?;
let variance = if idx < self.covariance.nrows() {
self.covariance[(idx, idx)]
} else {
f64::INFINITY
};
let observation_count = self.strengths.len();
Some(FitnessEstimate::new(strength, variance, observation_count))
}
pub fn all_estimates(&self) -> HashMap<CandidateId, FitnessEstimate> {
self.strengths
.keys()
.filter_map(|&id| self.get_estimate(id).map(|e| (id, e)))
.collect()
}
pub fn predict_win_probability(&self, a: CandidateId, b: CandidateId) -> Option<f64> {
let pa = self.strengths.get(&a)?;
let pb = self.strengths.get(&b)?;
Some(pa / (pa + pb))
}
}
pub struct BradleyTerryModel {
optimizer: BradleyTerryOptimizer,
}
impl BradleyTerryModel {
pub fn new(optimizer: BradleyTerryOptimizer) -> Self {
Self { optimizer }
}
pub fn fit(
&self,
comparisons: &[ComparisonRecord],
candidate_ids: &[CandidateId],
) -> BradleyTerryResult {
if candidate_ids.is_empty() || comparisons.is_empty() {
return self.empty_result(candidate_ids);
}
let ctx = FitContext::new(comparisons, candidate_ids);
match &self.optimizer {
BradleyTerryOptimizer::NewtonRaphson {
max_iterations,
tolerance,
prior_lambda,
} => self.fit_newton_raphson(&ctx, *max_iterations, *tolerance, *prior_lambda),
BradleyTerryOptimizer::MM {
max_iterations,
tolerance,
bootstrap_samples,
} => self.fit_mm(&ctx, *max_iterations, *tolerance, *bootstrap_samples),
}
}
fn empty_result(&self, candidate_ids: &[CandidateId]) -> BradleyTerryResult {
let n = candidate_ids.len();
let strengths: HashMap<CandidateId, f64> =
candidate_ids.iter().map(|&id| (id, 1.0)).collect();
let id_to_index: HashMap<CandidateId, usize> = candidate_ids
.iter()
.enumerate()
.map(|(i, &id)| (id, i))
.collect();
BradleyTerryResult {
strengths,
covariance: DMatrix::from_diagonal_element(n, n, f64::INFINITY),
id_to_index,
log_likelihood: 0.0,
iterations: 0,
converged: true,
convergence_metric: 0.0,
}
}
fn fit_newton_raphson(
&self,
ctx: &FitContext,
max_iterations: usize,
tolerance: f64,
prior_lambda: f64,
) -> BradleyTerryResult {
let n = ctx.n;
let comparisons = ctx.comparisons;
let candidate_ids = ctx.candidate_ids;
let id_to_index = &ctx.id_to_index;
let mut theta = DVector::zeros(n);
let mut converged = false;
let mut iterations = 0;
let mut gradient_norm = f64::INFINITY;
for iter in 0..max_iterations {
iterations = iter + 1;
let mut gradient = DVector::zeros(n);
let mut hessian = DMatrix::zeros(n, n);
for comp in comparisons {
let i = match id_to_index.get(&comp.winner) {
Some(&idx) => idx,
None => continue,
};
let j = match id_to_index.get(&comp.loser) {
Some(&idx) => idx,
None => continue,
};
let diff = theta[i] - theta[j];
let p = sigmoid(diff);
let q = 1.0 - p;
gradient[i] += q; gradient[j] -= q;
let h = p * q;
hessian[(i, i)] -= h;
hessian[(j, j)] -= h;
hessian[(i, j)] += h;
hessian[(j, i)] += h;
}
if prior_lambda > 0.0 {
for i in 0..n {
gradient[i] -= prior_lambda * theta[i];
hessian[(i, i)] -= prior_lambda;
}
}
gradient_norm = gradient.norm();
if gradient_norm < tolerance {
converged = true;
break;
}
let neg_hessian = -&hessian;
let delta = match neg_hessian.clone().lu().solve(&gradient) {
Some(d) => d,
None => {
let mut reg_hessian = neg_hessian;
let nudge = if prior_lambda > 0.0 {
prior_lambda
} else {
1e-6
};
for i in 0..n {
reg_hessian[(i, i)] += nudge;
}
match reg_hessian.lu().solve(&gradient) {
Some(d) => d,
None => break, }
}
};
let (new_theta, _backtracks) = self.backtracking_line_search(
&theta,
&delta,
&gradient,
comparisons,
id_to_index,
prior_lambda,
);
theta = new_theta;
let mean_theta = theta.mean();
theta -= DVector::from_element(n, mean_theta);
}
let strengths: HashMap<CandidateId, f64> = candidate_ids
.iter()
.enumerate()
.map(|(i, &id)| (id, theta[i].exp()))
.collect();
let covariance = self.strength_covariance(&theta, comparisons, id_to_index, n);
let log_likelihood = self.log_likelihood(&theta, comparisons, id_to_index);
BradleyTerryResult {
strengths,
covariance,
id_to_index: id_to_index.clone(),
log_likelihood,
iterations,
converged,
convergence_metric: gradient_norm,
}
}
fn backtracking_line_search(
&self,
theta: &DVector<f64>,
delta: &DVector<f64>,
gradient: &DVector<f64>,
comparisons: &[ComparisonRecord],
id_to_index: &HashMap<CandidateId, usize>,
prior_lambda: f64,
) -> (DVector<f64>, usize) {
const C1: f64 = 1e-4;
const MAX_BACKTRACKS: usize = 30;
let dir_deriv = gradient.dot(delta);
let current = self.penalized_log_likelihood(theta, comparisons, id_to_index, prior_lambda);
let mut step_size = 1.0;
for backtracks in 0..MAX_BACKTRACKS {
let candidate = theta + step_size * delta;
let candidate_ll =
self.penalized_log_likelihood(&candidate, comparisons, id_to_index, prior_lambda);
if armijo_sufficient_increase(current, candidate_ll, step_size, dir_deriv, C1) {
return (candidate, backtracks);
}
step_size *= 0.5;
}
(theta.clone(), MAX_BACKTRACKS)
}
fn penalized_log_likelihood(
&self,
theta: &DVector<f64>,
comparisons: &[ComparisonRecord],
id_to_index: &HashMap<CandidateId, usize>,
prior_lambda: f64,
) -> f64 {
self.log_likelihood(theta, comparisons, id_to_index) - 0.5 * prior_lambda * theta.dot(theta)
}
fn strength_covariance(
&self,
theta: &DVector<f64>,
comparisons: &[ComparisonRecord],
id_to_index: &HashMap<CandidateId, usize>,
n: usize,
) -> DMatrix<f64> {
let mut m = DMatrix::<f64>::zeros(n, n);
for comp in comparisons {
let i = match id_to_index.get(&comp.winner) {
Some(&idx) => idx,
None => continue,
};
let j = match id_to_index.get(&comp.loser) {
Some(&idx) => idx,
None => continue,
};
let p = sigmoid(theta[i] - theta[j]);
let h = p * (1.0 - p);
m[(i, i)] += h;
m[(j, j)] += h;
m[(i, j)] -= h;
m[(j, i)] -= h;
}
let cov_theta = match m.pseudo_inverse(1e-9) {
Ok(inv) => inv,
Err(_) => return DMatrix::from_diagonal_element(n, n, f64::INFINITY),
};
let pi: Vec<f64> = (0..n).map(|i| theta[i].exp()).collect();
let mut cov = DMatrix::<f64>::zeros(n, n);
for i in 0..n {
for j in 0..n {
cov[(i, j)] = pi[i] * pi[j] * cov_theta[(i, j)];
}
}
cov
}
fn fit_mm(
&self,
ctx: &FitContext,
max_iterations: usize,
tolerance: f64,
bootstrap_samples: usize,
) -> BradleyTerryResult {
let n = ctx.n;
let comparisons = ctx.comparisons;
let candidate_ids = ctx.candidate_ids;
let id_to_index = &ctx.id_to_index;
let (pi, iterations, converged, max_change) =
self.mm_core(comparisons, id_to_index, n, max_iterations, tolerance);
let covariance =
self.bootstrap_covariance(ctx, max_iterations, tolerance, bootstrap_samples, &pi);
let strengths: HashMap<CandidateId, f64> = candidate_ids
.iter()
.enumerate()
.map(|(i, &id)| (id, pi[i]))
.collect();
let theta: DVector<f64> = pi.iter().map(|&p| p.ln()).collect::<Vec<_>>().into();
let log_likelihood = self.log_likelihood(&theta, comparisons, id_to_index);
BradleyTerryResult {
strengths,
covariance,
id_to_index: id_to_index.clone(),
log_likelihood,
iterations,
converged,
convergence_metric: max_change,
}
}
fn mm_core(
&self,
comparisons: &[ComparisonRecord],
id_to_index: &HashMap<CandidateId, usize>,
n: usize,
max_iterations: usize,
tolerance: f64,
) -> (Vec<f64>, usize, bool, f64) {
let mut pi = vec![1.0; n];
let mut wins = vec![0usize; n];
for comp in comparisons {
if let Some(&idx) = id_to_index.get(&comp.winner) {
wins[idx] += 1;
}
}
let mut converged = false;
let mut iterations = 0;
let mut max_change = f64::INFINITY;
for iter in 0..max_iterations {
iterations = iter + 1;
let mut pi_new = vec![0.0; n];
for i in 0..n {
let mut denom = 0.0;
for comp in comparisons {
let w_idx = id_to_index.get(&comp.winner).copied();
let l_idx = id_to_index.get(&comp.loser).copied();
match (w_idx, l_idx) {
(Some(wi), Some(li)) if wi == i || li == i => {
let other = if wi == i { li } else { wi };
denom += 1.0 / (pi[i] + pi[other]);
}
_ => {}
}
}
let numerator = wins[i] as f64 + MM_PRIOR_PSEUDOCOUNT;
let denom = denom + MM_PRIOR_PSEUDOCOUNT;
pi_new[i] = if denom > 0.0 {
numerator / denom
} else {
pi[i]
};
}
let sum: f64 = pi_new.iter().sum();
if sum > 0.0 {
for p in &mut pi_new {
*p *= n as f64 / sum;
}
}
max_change = pi
.iter()
.zip(pi_new.iter())
.map(|(a, b)| (a - b).abs())
.fold(0.0, f64::max);
if max_change < tolerance {
converged = true;
pi = pi_new;
break;
}
pi = pi_new;
}
(pi, iterations, converged, max_change)
}
fn bootstrap_covariance(
&self,
ctx: &FitContext,
max_iterations: usize,
tolerance: f64,
bootstrap_samples: usize,
point_estimate: &[f64],
) -> DMatrix<f64> {
let n = ctx.n;
let comparisons = ctx.comparisons;
let id_to_index = &ctx.id_to_index;
if bootstrap_samples == 0 || comparisons.is_empty() {
return DMatrix::from_diagonal_element(n, n, f64::INFINITY);
}
let mut rng = rand::thread_rng();
let mut bootstrap_estimates: Vec<Vec<f64>> = Vec::with_capacity(bootstrap_samples);
for _ in 0..bootstrap_samples {
let resampled: Vec<ComparisonRecord> = (0..comparisons.len())
.map(|_| comparisons[rng.gen_range(0..comparisons.len())].clone())
.collect();
let (pi, _, _, _) = self.mm_core(&resampled, id_to_index, n, max_iterations, tolerance);
bootstrap_estimates.push(pi);
}
let mut covariance = DMatrix::zeros(n, n);
for i in 0..n {
for j in 0..n {
let mean_i = point_estimate[i];
let mean_j = point_estimate[j];
let cov: f64 = bootstrap_estimates
.iter()
.map(|est| (est[i] - mean_i) * (est[j] - mean_j))
.sum::<f64>()
/ (bootstrap_samples - 1).max(1) as f64;
covariance[(i, j)] = cov;
}
}
covariance
}
fn log_likelihood(
&self,
theta: &DVector<f64>,
comparisons: &[ComparisonRecord],
id_to_index: &HashMap<CandidateId, usize>,
) -> f64 {
let mut ll = 0.0;
for comp in comparisons {
let i = match id_to_index.get(&comp.winner) {
Some(&idx) => idx,
None => continue,
};
let j = match id_to_index.get(&comp.loser) {
Some(&idx) => idx,
None => continue,
};
let diff = theta[i] - theta[j];
ll += log_sigmoid(diff);
}
ll
}
}
const MM_PRIOR_PSEUDOCOUNT: f64 = 0.1;
fn armijo_sufficient_increase(
current: f64,
candidate: f64,
step: f64,
dir_deriv: f64,
c: f64,
) -> bool {
candidate >= current + c * step * dir_deriv
}
fn sigmoid(x: f64) -> f64 {
if x >= 0.0 {
1.0 / (1.0 + (-x).exp())
} else {
let ex = x.exp();
ex / (1.0 + ex)
}
}
fn log_sigmoid(x: f64) -> f64 {
if x >= 0.0 {
-(-x).exp().ln_1p()
} else {
x - x.exp().ln_1p()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_comparisons(pairs: &[(usize, usize)]) -> Vec<ComparisonRecord> {
pairs
.iter()
.map(|&(w, l)| ComparisonRecord {
winner: CandidateId(w),
loser: CandidateId(l),
generation: 0,
})
.collect()
}
#[test]
fn test_newton_raphson_basic() {
let comparisons = make_comparisons(&[(0, 1), (0, 1), (1, 2), (1, 2)]);
let candidate_ids = vec![CandidateId(0), CandidateId(1), CandidateId(2)];
let model = BradleyTerryModel::new(BradleyTerryOptimizer::default());
let result = model.fit(&comparisons, &candidate_ids);
assert!(result.converged);
let pa = result.strengths[&CandidateId(0)];
let pb = result.strengths[&CandidateId(1)];
let pc = result.strengths[&CandidateId(2)];
assert!(pa > pb);
assert!(pb > pc);
}
#[test]
fn test_mm_basic() {
let comparisons = make_comparisons(&[(0, 1), (0, 1), (1, 2), (1, 2)]);
let candidate_ids = vec![CandidateId(0), CandidateId(1), CandidateId(2)];
let model = BradleyTerryModel::new(BradleyTerryOptimizer::mm(100, 1e-8, 50));
let result = model.fit(&comparisons, &candidate_ids);
assert!(result.converged);
let pa = result.strengths[&CandidateId(0)];
let pb = result.strengths[&CandidateId(1)];
let pc = result.strengths[&CandidateId(2)];
assert!(pa > pb);
assert!(pb > pc);
}
#[test]
fn test_newton_raphson_and_mm_agree() {
let comparisons = make_comparisons(&[
(0, 1),
(0, 2),
(1, 2),
(0, 1),
(1, 0),
(2, 1),
(0, 2),
(0, 2),
]);
let candidate_ids = vec![CandidateId(0), CandidateId(1), CandidateId(2)];
let nr_model = BradleyTerryModel::new(BradleyTerryOptimizer::default());
let mm_model = BradleyTerryModel::new(BradleyTerryOptimizer::mm(100, 1e-8, 0));
let nr_result = nr_model.fit(&comparisons, &candidate_ids);
let mm_result = mm_model.fit(&comparisons, &candidate_ids);
let nr_ranking: Vec<_> = {
let mut r: Vec<_> = candidate_ids
.iter()
.map(|&id| (id, nr_result.strengths[&id]))
.collect();
r.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
r.into_iter().map(|(id, _)| id).collect()
};
let mm_ranking: Vec<_> = {
let mut r: Vec<_> = candidate_ids
.iter()
.map(|&id| (id, mm_result.strengths[&id]))
.collect();
r.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
r.into_iter().map(|(id, _)| id).collect()
};
assert_eq!(nr_ranking, mm_ranking);
}
#[test]
fn test_get_estimate() {
let comparisons = make_comparisons(&[(0, 1), (0, 1), (0, 1)]);
let candidate_ids = vec![CandidateId(0), CandidateId(1)];
let model = BradleyTerryModel::new(BradleyTerryOptimizer::default());
let result = model.fit(&comparisons, &candidate_ids);
let estimate = result.get_estimate(CandidateId(0)).unwrap();
assert!(estimate.variance < f64::INFINITY);
assert!(estimate.variance > 0.0);
}
#[test]
fn test_predict_win_probability() {
let comparisons = make_comparisons(&[(0, 1), (0, 1), (0, 1), (0, 1)]);
let candidate_ids = vec![CandidateId(0), CandidateId(1)];
let model = BradleyTerryModel::new(BradleyTerryOptimizer::default());
let result = model.fit(&comparisons, &candidate_ids);
let p = result
.predict_win_probability(CandidateId(0), CandidateId(1))
.unwrap();
assert!(p > 0.5); assert!(p < 1.0);
}
#[test]
fn test_empty_comparisons() {
let comparisons: Vec<ComparisonRecord> = vec![];
let candidate_ids = vec![CandidateId(0), CandidateId(1)];
let model = BradleyTerryModel::new(BradleyTerryOptimizer::default());
let result = model.fit(&comparisons, &candidate_ids);
assert!(result.converged);
assert!(result.covariance[(0, 0)].is_infinite());
}
#[test]
fn test_sigmoid() {
assert!((sigmoid(0.0) - 0.5).abs() < 1e-9);
assert!(sigmoid(100.0) > 0.999);
assert!(sigmoid(-100.0) < 0.001);
for x in [-5.0, -1.0, 0.0, 1.0, 5.0] {
assert!((sigmoid(-x) - (1.0 - sigmoid(x))).abs() < 1e-9);
}
}
#[test]
fn test_log_sigmoid() {
for x in [-5.0, -1.0, 0.0, 1.0, 5.0] {
assert!(log_sigmoid(x) <= 0.0);
assert!((log_sigmoid(x).exp() - sigmoid(x)).abs() < 1e-9);
}
}
#[test]
fn test_covariance_positive_semidefinite() {
let comparisons = make_comparisons(&[(0, 1), (0, 2), (1, 2), (0, 1), (1, 2), (0, 2)]);
let candidate_ids = vec![CandidateId(0), CandidateId(1), CandidateId(2)];
let model = BradleyTerryModel::new(BradleyTerryOptimizer::default());
let result = model.fit(&comparisons, &candidate_ids);
for i in 0..3 {
assert!(result.covariance[(i, i)] >= 0.0);
}
}
#[test]
fn test_constrained_fisher_covariance_matches_analytic() {
let comparisons = make_comparisons(&[(0, 1), (1, 0), (0, 2), (2, 0), (1, 2), (2, 1)]);
let candidate_ids = vec![CandidateId(0), CandidateId(1), CandidateId(2)];
let model = BradleyTerryModel::new(BradleyTerryOptimizer::default());
let result = model.fit(&comparisons, &candidate_ids);
let n = 3;
let mut m = DMatrix::<f64>::zeros(n, n);
for comp in &comparisons {
let i = comp.winner.0;
let j = comp.loser.0;
let h = 0.25;
m[(i, i)] += h;
m[(j, j)] += h;
m[(i, j)] -= h;
m[(j, i)] -= h;
}
let analytic = m.pseudo_inverse(1e-9).unwrap();
for i in 0..n {
assert!(
(result.covariance[(i, i)] - analytic[(i, i)]).abs() < 1e-6,
"diag {}: got {}, analytic {}",
i,
result.covariance[(i, i)],
analytic[(i, i)]
);
assert!((result.covariance[(i, i)] - 4.0 / 9.0).abs() < 1e-6);
}
assert!(result.covariance[(0, 0)] < 1.0);
}
#[test]
fn test_prior_keeps_all_win_all_loss_finite() {
let comparisons = make_comparisons(&[(0, 1), (0, 1), (0, 1), (0, 1), (0, 1)]);
let ids = vec![CandidateId(0), CandidateId(1)];
let nr = BradleyTerryModel::new(BradleyTerryOptimizer::default());
let r = nr.fit(&comparisons, &ids);
let s0 = r.strengths[&CandidateId(0)];
let s1 = r.strengths[&CandidateId(1)];
assert!(s0.is_finite() && s1.is_finite());
assert!(s0 > s1);
assert!(s0 < 50.0, "NR strength diverged: {}", s0);
assert!(s1 > 0.0, "NR loser strength collapsed: {}", s1);
let mm = BradleyTerryModel::new(BradleyTerryOptimizer::mm(200, 1e-9, 0));
let rm = mm.fit(&comparisons, &ids);
let m0 = rm.strengths[&CandidateId(0)];
let m1 = rm.strengths[&CandidateId(1)];
assert!(m0.is_finite() && m0 < 50.0, "MM strength diverged: {}", m0);
assert!(m0 > m1);
assert!(m1 > 0.0);
}
#[test]
fn test_armijo_sign_rejects_small_decrease() {
let current = 10.0;
let candidate = 9.99995; let step = 1.0;
let dir_deriv = 1.0; let c = 1e-4;
assert!(!armijo_sufficient_increase(
current, candidate, step, dir_deriv, c
));
assert!(armijo_sufficient_increase(
current, 10.5, step, dir_deriv, c
));
let buggy_threshold = current - c * step * dir_deriv;
assert!(candidate > buggy_threshold);
}
#[test]
fn test_backtracking_triggers_on_overshoot() {
let comparisons = make_comparisons(&[(0, 1), (0, 1), (1, 2), (1, 2)]);
let ids = [CandidateId(0), CandidateId(1), CandidateId(2)];
let id_to_index: HashMap<CandidateId, usize> =
ids.iter().enumerate().map(|(i, &id)| (id, i)).collect();
let model = BradleyTerryModel::new(BradleyTerryOptimizer::default());
let lambda = 0.1;
let theta = DVector::from_element(3, 0.0);
let mut gradient = DVector::zeros(3);
let mut hessian = DMatrix::zeros(3, 3);
for comp in &comparisons {
let i = id_to_index[&comp.winner];
let j = id_to_index[&comp.loser];
let p = sigmoid(theta[i] - theta[j]);
let q = 1.0 - p;
let h = p * q;
gradient[i] += q;
gradient[j] -= q;
hessian[(i, i)] -= h;
hessian[(j, j)] -= h;
hessian[(i, j)] += h;
hessian[(j, i)] += h;
}
for i in 0..3 {
gradient[i] -= lambda * theta[i];
hessian[(i, i)] -= lambda;
}
let newton = (-&hessian).lu().solve(&gradient).unwrap();
let big_delta = 50.0 * &newton;
let before = model.penalized_log_likelihood(&theta, &comparisons, &id_to_index, lambda);
let (new_theta, backtracks) = model.backtracking_line_search(
&theta,
&big_delta,
&gradient,
&comparisons,
&id_to_index,
lambda,
);
let after = model.penalized_log_likelihood(&new_theta, &comparisons, &id_to_index, lambda);
assert!(backtracks >= 1, "expected backtracking to trigger");
assert!(
after >= before,
"line search must not decrease the objective"
);
}
}