Skip to content

Commit 0c46387

Browse files
committed
added Damped Sinusoid
1 parent 9250cbf commit 0c46387

10 files changed

Lines changed: 244 additions & 12 deletions

File tree

README.md

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,29 @@
11
# curve-fit
22

3-
`curve-fit` — учебное приложение для подбора параметров кривой по набору точек. Основаня цель приложения это наработка интуиции по модельным кривым.
3+
`curve-fit` — учебное приложение для подбора параметров кривой по набору точек.
4+
Главная цель проекта: нарабатывать интуицию о том, как ведут себя разные модели и оптимизаторы
5+
на реальных и синтетических данных.
6+
7+
Проект не пытается быть “production-утилитой” для универсального фитинга: это песочница для экспериментов и обучения.
48

59
[![CI](https://github.com/hexqnt/curve-fit/actions/workflows/ci.yml/badge.svg)](https://github.com/hexqnt/curve-fit/actions/workflows/ci.yml)
610

7-
![Alt text](images/curve-fit-screenshot.png "Optional title text")
11+
![curve-fit screenshot](images/curve-fit-screenshot.png)
12+
13+
## Что внутри
14+
15+
- Параметрические семейства:
16+
- полиномы, экспоненциальные, сигмоиды, пиковые модели, степенные, двухэкспоненциальные и осциллирующие (затухающая синусоида),
17+
- cплайны: линейный, монотонный кубический (PCHIP), натуральный кубический, Akima.
18+
- Несколько оптимизаторов и метрик лосса для сравнения динамики сходимости.
19+
- Диагностика итераций и визуализация результата в интерактивном UI.
20+
21+
## Desktop vs Web
822

9-
Отличие десктопной версии от web-версии:
23+
Десктопная версия имеет:
1024

1125
- чуть выше производительность
12-
- нет подвисания при обучении(фитинге кривой)
26+
- меньше риск подвисаний во время обучения (фитинга)
1327

1428
## Run Desktop
1529

@@ -19,7 +33,7 @@ cargo run
1933

2034
## Run Web (wasm)
2135

22-
1. Установить таргет:
36+
1. Установить target:
2337

2438
```bash
2539
rustup target add wasm32-unknown-unknown

src/app.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ enum ModelChoice {
212212
Logistic,
213213
Gompertz,
214214
BiExponential,
215+
DampedSinusoid,
215216
Lorentzian,
216217
NaturalLog,
217218
FourPl,
@@ -233,13 +234,14 @@ enum ModelChoice {
233234
}
234235

235236
impl ModelChoice {
236-
const ALL: [Self; 24] = [
237+
const ALL: [Self; 25] = [
237238
Self::Polynomial,
238239
Self::Arrhenius,
239240
Self::Inverse,
240241
Self::Logistic,
241242
Self::Gompertz,
242243
Self::BiExponential,
244+
Self::DampedSinusoid,
243245
Self::Lorentzian,
244246
Self::NaturalLog,
245247
Self::FourPl,
@@ -283,6 +285,7 @@ impl ResolvedModel {
283285
ModelChoice::Logistic => Self::Parametric(CurveFamily::Logistic),
284286
ModelChoice::Gompertz => Self::Parametric(CurveFamily::Gompertz),
285287
ModelChoice::BiExponential => Self::Parametric(CurveFamily::BiExponential),
288+
ModelChoice::DampedSinusoid => Self::Parametric(CurveFamily::DampedSinusoid),
286289
ModelChoice::Lorentzian => Self::Parametric(CurveFamily::Lorentzian),
287290
ModelChoice::NaturalLog => Self::Parametric(CurveFamily::NaturalLog),
288291
ModelChoice::FourPl => Self::Parametric(CurveFamily::FourPl),
@@ -371,6 +374,7 @@ fn model_group(model: ModelChoice) -> ModelGroup {
371374
ModelChoice::Arrhenius
372375
| ModelChoice::Inverse
373376
| ModelChoice::BiExponential
377+
| ModelChoice::DampedSinusoid
374378
| ModelChoice::NaturalLog
375379
| ModelChoice::MichaelisMenten
376380
| ModelChoice::ExponentialBasic

src/app/formula.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ fn model_formula_full(model: ModelChoice, polynomial_degree: usize) -> String {
1414
ModelChoice::BiExponential => {
1515
r"y = a_{1}·exp(-k_{1}·x) + a_{2}·exp(-k_{2}·x) + c".to_string()
1616
}
17+
ModelChoice::DampedSinusoid => r"y = A·exp(-k·x)·sin(\omega·x + \phi) + c".to_string(),
1718
ModelChoice::Lorentzian => r"y = C + \frac{A}{1 + (\frac{x - x_0}{gamma})^{2}}".to_string(),
1819
ModelChoice::NaturalLog => r"y = A·ln(\frac{x}{B})".to_string(),
1920
ModelChoice::FourPl => r"y = d + \frac{a - d}{1 + (\frac{x}{c})^{b}}".to_string(),
@@ -116,6 +117,9 @@ fn model_ml_note(language: UiLanguage, model: ModelChoice) -> &'static str {
116117
(UiLanguage::English, ModelChoice::BiExponential) => {
117118
"Two-timescale exponential model with strong parameter coupling."
118119
}
120+
(UiLanguage::English, ModelChoice::DampedSinusoid) => {
121+
"Oscillatory model with damping; often has many local minima."
122+
}
119123
(UiLanguage::English, ModelChoice::Lorentzian) => "Peak-shaped model with heavy tails.",
120124
(UiLanguage::English, ModelChoice::NaturalLog) => {
121125
"Log transform response, useful for diminishing returns."
@@ -163,6 +167,9 @@ fn model_ml_note(language: UiLanguage, model: ModelChoice) -> &'static str {
163167
(UiLanguage::Russian, ModelChoice::BiExponential) => {
164168
"Двухэкспоненциальная модель с сильной связью параметров."
165169
}
170+
(UiLanguage::Russian, ModelChoice::DampedSinusoid) => {
171+
"Осциллирующая модель с затуханием и множеством локальных минимумов."
172+
}
166173
(UiLanguage::Russian, ModelChoice::Lorentzian) => {
167174
"Пиковая модель с более тяжёлыми хвостами."
168175
}

src/app/i18n.rs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -190,17 +190,17 @@ pub(super) fn param_init_method_disabled_label(
190190
) -> &'static str {
191191
match (language, method) {
192192
(UiLanguage::English, ParamInitMethod::DataBased) => {
193-
"Data-based (Polynomial/Logistic/Gompertz/Bi-Exponential/Gaussian/Exponential (Basic)/Power only)"
193+
"Data-based (Polynomial/Logistic/Gompertz/Bi-Exponential/Damped Sinusoid/Gaussian/Exponential (Basic)/Power only)"
194194
}
195195
(UiLanguage::English, ParamInitMethod::Randomized) => {
196-
"Randomized (Polynomial/Logistic/Gompertz/Bi-Exponential/Gaussian/Exponential (Basic)/Power only)"
196+
"Randomized (Polynomial/Logistic/Gompertz/Bi-Exponential/Damped Sinusoid/Gaussian/Exponential (Basic)/Power only)"
197197
}
198198
(UiLanguage::English, ParamInitMethod::Default) => "Default",
199199
(UiLanguage::Russian, ParamInitMethod::DataBased) => {
200-
"По данным (только Polynomial/Logistic/Gompertz/Bi-Exponential/Gaussian/Exponential (Basic)/Power)"
200+
"По данным (только Polynomial/Logistic/Gompertz/Bi-Exponential/Damped Sinusoid/Gaussian/Exponential (Basic)/Power)"
201201
}
202202
(UiLanguage::Russian, ParamInitMethod::Randomized) => {
203-
"Случайно (только Polynomial/Logistic/Gompertz/Bi-Exponential/Gaussian/Exponential (Basic)/Power)"
203+
"Случайно (только Polynomial/Logistic/Gompertz/Bi-Exponential/Damped Sinusoid/Gaussian/Exponential (Basic)/Power)"
204204
}
205205
(UiLanguage::Russian, ParamInitMethod::Default) => "По умолчанию",
206206
}
@@ -214,6 +214,7 @@ pub(super) fn model_choice_label(language: UiLanguage, model: ModelChoice) -> &'
214214
(UiLanguage::English, ModelChoice::Logistic) => "Logistic",
215215
(UiLanguage::English, ModelChoice::Gompertz) => "Gompertz",
216216
(UiLanguage::English, ModelChoice::BiExponential) => "Bi-Exponential",
217+
(UiLanguage::English, ModelChoice::DampedSinusoid) => "Damped Sinusoid",
217218
(UiLanguage::English, ModelChoice::Lorentzian) => "Lorentzian",
218219
(UiLanguage::English, ModelChoice::NaturalLog) => "Natural Log",
219220
(UiLanguage::English, ModelChoice::FourPl) => "4PL",
@@ -238,6 +239,7 @@ pub(super) fn model_choice_label(language: UiLanguage, model: ModelChoice) -> &'
238239
(UiLanguage::Russian, ModelChoice::Logistic) => "Логистическая",
239240
(UiLanguage::Russian, ModelChoice::Gompertz) => "Гомпертц",
240241
(UiLanguage::Russian, ModelChoice::BiExponential) => "Би-экспонента",
242+
(UiLanguage::Russian, ModelChoice::DampedSinusoid) => "Затухающая синусоида",
241243
(UiLanguage::Russian, ModelChoice::Lorentzian) => "Лоренциан",
242244
(UiLanguage::Russian, ModelChoice::NaturalLog) => "Натуральный логарифм",
243245
(UiLanguage::Russian, ModelChoice::FourPl) => "4PL",
@@ -275,6 +277,7 @@ pub(super) fn family_label(language: UiLanguage, family: CurveFamily) -> &'stati
275277
(UiLanguage::English, CurveFamily::Logistic) => "Logistic",
276278
(UiLanguage::English, CurveFamily::Gompertz) => "Gompertz",
277279
(UiLanguage::English, CurveFamily::BiExponential) => "Bi-Exponential",
280+
(UiLanguage::English, CurveFamily::DampedSinusoid) => "Damped Sinusoid",
278281
(UiLanguage::English, CurveFamily::Lorentzian) => "Lorentzian",
279282
(UiLanguage::English, CurveFamily::NaturalLog) => "Natural Log",
280283
(UiLanguage::English, CurveFamily::FourPl) => "4PL",
@@ -303,6 +306,7 @@ pub(super) fn family_label(language: UiLanguage, family: CurveFamily) -> &'stati
303306
(UiLanguage::Russian, CurveFamily::Logistic) => "Логистическая",
304307
(UiLanguage::Russian, CurveFamily::Gompertz) => "Гомпертц",
305308
(UiLanguage::Russian, CurveFamily::BiExponential) => "Би-экспонента",
309+
(UiLanguage::Russian, CurveFamily::DampedSinusoid) => "Затухающая синусоида",
306310
(UiLanguage::Russian, CurveFamily::Lorentzian) => "Лоренциан",
307311
(UiLanguage::Russian, CurveFamily::NaturalLog) => "Натуральный логарифм",
308312
(UiLanguage::Russian, CurveFamily::FourPl) => "4PL",

src/app/normalization.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,19 @@ impl ParametricNormalization {
138138
values[4] *= y_scale;
139139
}
140140
}
141+
CurveFamily::DampedSinusoid => {
142+
if to_normalized {
143+
values[0] /= y_scale;
144+
values[1] *= x_scale;
145+
values[2] *= x_scale;
146+
values[4] /= y_scale;
147+
} else {
148+
values[0] *= y_scale;
149+
values[1] /= x_scale;
150+
values[2] /= x_scale;
151+
values[4] *= y_scale;
152+
}
153+
}
141154
CurveFamily::Lorentzian => {
142155
if to_normalized {
143156
values[0] /= y_scale;

src/app/param_init.rs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ pub(super) fn is_advanced_param_init_supported(family: CurveFamily) -> bool {
2323
CurveFamily::Logistic
2424
| CurveFamily::Gompertz
2525
| CurveFamily::BiExponential
26+
| CurveFamily::DampedSinusoid
2627
| CurveFamily::Gaussian
2728
| CurveFamily::ExponentialBasic
2829
| CurveFamily::Power
@@ -44,6 +45,7 @@ pub(super) fn data_based_params_for_family(
4445
CurveFamily::Logistic => data_based_logistic_params(points),
4546
CurveFamily::Gompertz => data_based_gompertz_params(points),
4647
CurveFamily::BiExponential => data_based_bi_exponential_params(points),
48+
CurveFamily::DampedSinusoid => data_based_damped_sinusoid_params(points),
4749
CurveFamily::Gaussian => data_based_gaussian_params(points),
4850
CurveFamily::ExponentialBasic => data_based_exponential_basic_params(points),
4951
CurveFamily::Power => data_based_power_params(points),
@@ -95,6 +97,34 @@ fn data_based_bi_exponential_params(points: &Points) -> Result<CurveParams, Stri
9597
.map_err(|error| error.to_string())
9698
}
9799

100+
fn data_based_damped_sinusoid_params(points: &Points) -> Result<CurveParams, String> {
101+
let sorted = sorted_by_x(points);
102+
let first = sorted[0];
103+
let last = sorted[sorted.len() - 1];
104+
let x_span = (last.x() - first.x()).abs().max(PARAM_INIT_SPAN_EPS);
105+
let (_, _, y_min, y_max, _) = point_extrema(points);
106+
let center = mean_y(points);
107+
let amplitude = ((y_max - y_min) * 0.5).abs().max(PARAM_INIT_SPAN_EPS);
108+
let k = 0.5 / x_span;
109+
let zero_crossings = count_centered_sign_changes(&sorted, center);
110+
let omega = if zero_crossings > 0 {
111+
std::f64::consts::PI * zero_crossings as f64 / x_span
112+
} else {
113+
std::f64::consts::TAU / x_span
114+
};
115+
let denom = (amplitude * (-k * first.x()).exp())
116+
.abs()
117+
.max(PARAM_INIT_SPAN_EPS);
118+
let ratio = ((first.y() - center) / denom).clamp(-1.0, 1.0);
119+
let phi = ratio.asin() - omega * first.x();
120+
121+
CurveParams::try_from_values(
122+
CurveFamily::DampedSinusoid,
123+
vec![amplitude, k, omega, phi, center],
124+
)
125+
.map_err(|error| error.to_string())
126+
}
127+
98128
fn data_based_gaussian_params(points: &Points) -> Result<CurveParams, String> {
99129
let (x_min, x_max, _, y_max, x_at_y_max) = point_extrema(points);
100130
let x_span = (x_max - x_min).max(PARAM_INIT_SPAN_EPS);
@@ -225,3 +255,28 @@ fn y_at_x_bounds(points: &Points) -> (f64, f64) {
225255

226256
(y_at_min_x, y_at_max_x)
227257
}
258+
259+
fn mean_y(points: &Points) -> f64 {
260+
points.as_slice().iter().map(|point| point.y()).sum::<f64>() / points.len() as f64
261+
}
262+
263+
fn sorted_by_x(points: &Points) -> Vec<crate::domain::Point> {
264+
let mut sorted = points.as_slice().to_vec();
265+
sorted.sort_by(|left, right| left.x().total_cmp(&right.x()));
266+
sorted
267+
}
268+
269+
fn count_centered_sign_changes(sorted: &[crate::domain::Point], center: f64) -> usize {
270+
let mut sign_changes = 0_usize;
271+
let mut previous = sorted[0].y() - center;
272+
for point in sorted.iter().skip(1) {
273+
let current = point.y() - center;
274+
if previous * current < 0.0 {
275+
sign_changes += 1;
276+
}
277+
if current.abs() > PARAM_INIT_SPAN_EPS {
278+
previous = current;
279+
}
280+
}
281+
sign_changes
282+
}

src/app/tests.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -631,6 +631,8 @@ fn param_init_method_support_matrix_is_correct() {
631631
assert!(ParamInitMethod::Default.is_supported_for_family(CurveFamily::Arrhenius));
632632
assert!(ParamInitMethod::DataBased.is_supported_for_family(CurveFamily::Linear));
633633
assert!(ParamInitMethod::Randomized.is_supported_for_family(CurveFamily::Power));
634+
assert!(ParamInitMethod::DataBased.is_supported_for_family(CurveFamily::BiExponential));
635+
assert!(ParamInitMethod::Randomized.is_supported_for_family(CurveFamily::DampedSinusoid));
634636

635637
assert!(!ParamInitMethod::DataBased.is_supported_for_family(CurveFamily::Arrhenius));
636638
assert!(!ParamInitMethod::Randomized.is_supported_for_family(CurveFamily::FourPl));
@@ -660,6 +662,49 @@ fn data_based_power_initialization_rejects_non_positive_y() {
660662
assert!(error.contains("requires y > 0"));
661663
}
662664

665+
#[test]
666+
fn data_based_bi_exponential_initialization_returns_finite_values() {
667+
let points = points_from_pairs(&[
668+
(0.0, 2.7),
669+
(0.4, 2.1),
670+
(0.9, 1.5),
671+
(1.6, 1.0),
672+
(2.3, 0.7),
673+
(3.2, 0.5),
674+
]);
675+
let params = data_based_params_for_family(CurveFamily::BiExponential, &points)
676+
.expect("must initialize bi-exponential params");
677+
let values = params.values();
678+
679+
assert_eq!(values.len(), CurveFamily::BiExponential.parameter_count());
680+
assert!(values.iter().all(|value| value.is_finite()));
681+
assert!(values[1] > 0.0, "k1 must be positive");
682+
assert!(values[3] > 0.0, "k2 must be positive");
683+
}
684+
685+
#[test]
686+
fn data_based_damped_sinusoid_initialization_returns_finite_values() {
687+
let points = points_from_pairs(&[
688+
(0.0, 0.6),
689+
(0.5, 1.2),
690+
(1.0, 0.3),
691+
(1.5, -0.8),
692+
(2.0, -0.5),
693+
(2.5, 0.4),
694+
(3.0, 0.7),
695+
(3.5, 0.1),
696+
(4.0, -0.4),
697+
]);
698+
let params = data_based_params_for_family(CurveFamily::DampedSinusoid, &points)
699+
.expect("must initialize damped sinusoid params");
700+
let values = params.values();
701+
702+
assert_eq!(values.len(), CurveFamily::DampedSinusoid.parameter_count());
703+
assert!(values.iter().all(|value| value.is_finite()));
704+
assert!(values[1] > 0.0, "k must be positive");
705+
assert!(values[2] > 0.0, "omega must be positive");
706+
}
707+
663708
#[test]
664709
fn randomized_initialization_stays_within_expected_range() {
665710
let mut app = CurveFitApp::default();

0 commit comments

Comments
 (0)