LeoBorai commited on
Commit
4d5cec0
·
1 Parent(s): 3fdf998

feat: add metrics

Browse files
model/src/bin/main.rs CHANGED
@@ -4,6 +4,7 @@ use clap::{Parser, Subcommand};
4
  use model::Model;
5
 
6
  pub const MODEL_PATH: &str = "assets/model.json";
 
7
  pub const TRAIN_DATASET_PATH: &str = "assets/train.csv";
8
  pub const TEST_DATASET_PATH: &str = "assets/test.csv";
9
 
@@ -56,6 +57,7 @@ fn predict_command(text: &str) -> anyhow::Result<()> {
56
  fn train_command() -> anyhow::Result<()> {
57
  Model::train(
58
  MODEL_PATH.into(),
 
59
  TRAIN_DATASET_PATH.into(),
60
  TEST_DATASET_PATH.into(),
61
  )?;
 
4
  use model::Model;
5
 
6
  pub const MODEL_PATH: &str = "assets/model.json";
7
+ pub const METRICS_PATH: &str = "assets/metrics.json";
8
  pub const TRAIN_DATASET_PATH: &str = "assets/train.csv";
9
  pub const TEST_DATASET_PATH: &str = "assets/test.csv";
10
 
 
57
  fn train_command() -> anyhow::Result<()> {
58
  Model::train(
59
  MODEL_PATH.into(),
60
+ METRICS_PATH.into(),
61
  TRAIN_DATASET_PATH.into(),
62
  TEST_DATASET_PATH.into(),
63
  )?;
model/src/lib.rs CHANGED
@@ -1,3 +1,4 @@
1
  pub mod model;
2
 
3
  pub use model::Model;
 
 
1
  pub mod model;
2
 
3
  pub use model::Model;
4
+ pub use model::metrics::Metrics;
model/src/model.rs CHANGED
@@ -6,11 +6,13 @@ use ndarray::Array1;
6
  use serde::{Deserialize, Serialize};
7
 
8
  use crate::model::logistic_regression::LogisticRegression;
 
9
  use crate::model::tfidf::TfIdf;
10
 
11
  #[cfg(feature = "training")]
12
  mod gpu;
13
  pub mod logistic_regression;
 
14
  pub mod tfidf;
15
 
16
  /// A dataset record
@@ -109,6 +111,7 @@ impl Model {
109
 
110
  pub fn train(
111
  model_path: PathBuf,
 
112
  train_dataset: PathBuf,
113
  test_dataset: PathBuf,
114
  ) -> anyhow::Result<()> {
@@ -130,19 +133,18 @@ impl Model {
130
  println!("Training...");
131
  let model = Self::fit_logreg(&x_train, &y_train, vectorizer.vocab_size())?;
132
 
133
- let predictions = model.predict(&x_test);
134
- let correct = predictions
135
- .iter()
136
- .zip(y_test.iter())
137
- .filter(|(p, y)| **p == (**y >= 0.5))
138
- .count();
139
- let accuracy = correct as f64 / y_test.len() as f64;
140
 
141
  println!(
142
- "\n Test accuracy: {:.2}% ({}/{})",
143
- accuracy * 100.0,
144
- correct,
145
- y_test.len()
 
 
 
146
  );
147
 
148
  // Try it on a couple of brand-new sentences.
@@ -165,6 +167,9 @@ impl Model {
165
  model.save(model_path.clone())?;
166
  println!("\nSaved trained model to {}", model_path.to_string_lossy());
167
 
 
 
 
168
  Ok(())
169
  }
170
  }
 
6
  use serde::{Deserialize, Serialize};
7
 
8
  use crate::model::logistic_regression::LogisticRegression;
9
+ use crate::model::metrics::Metrics;
10
  use crate::model::tfidf::TfIdf;
11
 
12
  #[cfg(feature = "training")]
13
  mod gpu;
14
  pub mod logistic_regression;
15
+ pub mod metrics;
16
  pub mod tfidf;
17
 
18
  /// A dataset record
 
111
 
112
  pub fn train(
113
  model_path: PathBuf,
114
+ metrics_path: PathBuf,
115
  train_dataset: PathBuf,
116
  test_dataset: PathBuf,
117
  ) -> anyhow::Result<()> {
 
133
  println!("Training...");
134
  let model = Self::fit_logreg(&x_train, &y_train, vectorizer.vocab_size())?;
135
 
136
+ let predictions: Vec<bool> = model.predict(&x_test).iter().copied().collect();
137
+ let labels: Vec<f64> = y_test.iter().copied().collect();
138
+ let metrics = Metrics::compute(&predictions, &labels);
 
 
 
 
139
 
140
  println!(
141
+ "\nTest metrics: accuracy={:.2}% precision={:.2}% recall={:.2}% f1={:.2}% ({}/{})",
142
+ metrics.accuracy * 100.0,
143
+ metrics.precision * 100.0,
144
+ metrics.recall * 100.0,
145
+ metrics.f1 * 100.0,
146
+ metrics.correct,
147
+ metrics.total
148
  );
149
 
150
  // Try it on a couple of brand-new sentences.
 
167
  model.save(model_path.clone())?;
168
  println!("\nSaved trained model to {}", model_path.to_string_lossy());
169
 
170
+ metrics.save(metrics_path.clone())?;
171
+ println!("Saved metrics to {}", metrics_path.to_string_lossy());
172
+
173
  Ok(())
174
  }
175
  }
model/src/model/metrics.rs ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::fs::File;
2
+ use std::path::PathBuf;
3
+
4
+ use anyhow::{Context, Result};
5
+ use serde::{Deserialize, Serialize};
6
+
7
+ /// Test-set evaluation metrics for a trained [`crate::Model`].
8
+ #[derive(Debug, Clone, Serialize, Deserialize)]
9
+ pub struct Metrics {
10
+ pub accuracy: f64,
11
+ pub precision: f64,
12
+ pub recall: f64,
13
+ pub f1: f64,
14
+ pub correct: usize,
15
+ pub total: usize,
16
+ }
17
+
18
+ impl Metrics {
19
+ /// Computes accuracy/precision/recall/F1 from predicted vs. actual labels.
20
+ /// "positive" is the `true`/`>= 0.5` class.
21
+ pub fn compute(predictions: &[bool], labels: &[f64]) -> Self {
22
+ let mut true_positives = 0usize;
23
+ let mut false_positives = 0usize;
24
+ let mut true_negatives = 0usize;
25
+ let mut false_negatives = 0usize;
26
+
27
+ for (&predicted, &label) in predictions.iter().zip(labels.iter()) {
28
+ let actual = label >= 0.5;
29
+ match (predicted, actual) {
30
+ (true, true) => true_positives += 1,
31
+ (true, false) => false_positives += 1,
32
+ (false, false) => true_negatives += 1,
33
+ (false, true) => false_negatives += 1,
34
+ }
35
+ }
36
+
37
+ let total = predictions.len();
38
+ let correct = true_positives + true_negatives;
39
+ let accuracy = correct as f64 / total as f64;
40
+
41
+ let precision = if true_positives + false_positives == 0 {
42
+ 0.0
43
+ } else {
44
+ true_positives as f64 / (true_positives + false_positives) as f64
45
+ };
46
+
47
+ let recall = if true_positives + false_negatives == 0 {
48
+ 0.0
49
+ } else {
50
+ true_positives as f64 / (true_positives + false_negatives) as f64
51
+ };
52
+
53
+ let f1 = if precision + recall == 0.0 {
54
+ 0.0
55
+ } else {
56
+ 2.0 * precision * recall / (precision + recall)
57
+ };
58
+
59
+ Self {
60
+ accuracy,
61
+ precision,
62
+ recall,
63
+ f1,
64
+ correct,
65
+ total,
66
+ }
67
+ }
68
+
69
+ pub fn save(&self, path: PathBuf) -> Result<()> {
70
+ let file = File::create(path)?;
71
+ serde_json::to_writer_pretty(file, self).context("Failed to save metrics")
72
+ }
73
+
74
+ pub fn load(path: PathBuf) -> Result<Self> {
75
+ let file = File::open(path)?;
76
+ serde_json::from_reader(file).context("Failed to load metrics")
77
+ }
78
+ }
79
+
80
+ #[cfg(test)]
81
+ mod tests {
82
+ use super::Metrics;
83
+
84
+ #[test]
85
+ fn computes_confusion_matrix_metrics() {
86
+ // 2 TP, 1 FP, 1 TN, 1 FN
87
+ let predictions = [true, true, true, false, false];
88
+ let labels = [1.0, 1.0, 0.0, 0.0, 1.0];
89
+
90
+ let metrics = Metrics::compute(&predictions, &labels);
91
+
92
+ assert_eq!(metrics.total, 5);
93
+ assert_eq!(metrics.correct, 3);
94
+ assert!((metrics.accuracy - 0.6).abs() < 1e-12);
95
+ assert!((metrics.precision - 2.0 / 3.0).abs() < 1e-12);
96
+ assert!((metrics.recall - 2.0 / 3.0).abs() < 1e-12);
97
+ assert!((metrics.f1 - 2.0 / 3.0).abs() < 1e-12);
98
+ }
99
+
100
+ #[test]
101
+ fn handles_no_positive_predictions() {
102
+ let predictions = [false, false];
103
+ let labels = [1.0, 0.0];
104
+
105
+ let metrics = Metrics::compute(&predictions, &labels);
106
+
107
+ assert_eq!(metrics.precision, 0.0);
108
+ assert_eq!(metrics.recall, 0.0);
109
+ assert_eq!(metrics.f1, 0.0);
110
+ }
111
+ }
server/src/main.rs CHANGED
@@ -8,9 +8,15 @@ use axum::routing::{get, post};
8
  use axum::{Form, Router};
9
  use serde::Deserialize;
10
 
11
- use model::Model;
12
 
13
  pub const MODEL_PATH: &str = "assets/model.json";
 
 
 
 
 
 
14
 
15
  #[derive(Clone)]
16
  struct PredictionView {
@@ -18,11 +24,31 @@ struct PredictionView {
18
  prob: String,
19
  }
20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  #[derive(Template)]
22
  #[template(path = "index.html")]
23
  struct IndexTemplate {
24
  text: String,
25
  result: Option<PredictionView>,
 
26
  }
27
 
28
  #[derive(Deserialize)]
@@ -43,15 +69,19 @@ impl IntoResponse for IndexTemplate {
43
  }
44
  }
45
 
46
- async fn index() -> IndexTemplate {
47
  IndexTemplate {
48
  text: String::new(),
49
  result: None,
 
50
  }
51
  }
52
 
53
- async fn predict(State(model): State<Arc<Model>>, Form(form): Form<PredictForm>) -> IndexTemplate {
54
- let (label, prob) = model.predict(&form.text);
 
 
 
55
 
56
  IndexTemplate {
57
  text: form.text,
@@ -59,6 +89,7 @@ async fn predict(State(model): State<Arc<Model>>, Form(form): Form<PredictForm>)
59
  label,
60
  prob: format!("{prob:.2}"),
61
  }),
 
62
  }
63
  }
64
 
@@ -66,7 +97,8 @@ async fn predict(State(model): State<Arc<Model>>, Form(form): Form<PredictForm>)
66
  async fn main() -> anyhow::Result<()> {
67
  let model = Model::load(MODEL_PATH.into())
68
  .context("couldn't load {MODEL_PATH} ({e}) - run `cargo run --release -- train` first")?;
69
- let state = Arc::new(model);
 
70
 
71
  let app = Router::new()
72
  .route("/", get(index))
 
8
  use axum::{Form, Router};
9
  use serde::Deserialize;
10
 
11
+ use model::{Metrics, Model};
12
 
13
  pub const MODEL_PATH: &str = "assets/model.json";
14
+ pub const METRICS_PATH: &str = "assets/metrics.json";
15
+
16
+ struct AppState {
17
+ model: Model,
18
+ metrics: Option<MetricsView>,
19
+ }
20
 
21
  #[derive(Clone)]
22
  struct PredictionView {
 
24
  prob: String,
25
  }
26
 
27
+ #[derive(Clone)]
28
+ struct MetricsView {
29
+ accuracy: String,
30
+ precision: String,
31
+ recall: String,
32
+ f1: String,
33
+ }
34
+
35
+ impl From<Metrics> for MetricsView {
36
+ fn from(metrics: Metrics) -> Self {
37
+ Self {
38
+ accuracy: format!("{:.2}", metrics.accuracy * 100.0),
39
+ precision: format!("{:.2}", metrics.precision * 100.0),
40
+ recall: format!("{:.2}", metrics.recall * 100.0),
41
+ f1: format!("{:.2}", metrics.f1 * 100.0),
42
+ }
43
+ }
44
+ }
45
+
46
  #[derive(Template)]
47
  #[template(path = "index.html")]
48
  struct IndexTemplate {
49
  text: String,
50
  result: Option<PredictionView>,
51
+ metrics: Option<MetricsView>,
52
  }
53
 
54
  #[derive(Deserialize)]
 
69
  }
70
  }
71
 
72
+ async fn index(State(state): State<Arc<AppState>>) -> IndexTemplate {
73
  IndexTemplate {
74
  text: String::new(),
75
  result: None,
76
+ metrics: state.metrics.clone(),
77
  }
78
  }
79
 
80
+ async fn predict(
81
+ State(state): State<Arc<AppState>>,
82
+ Form(form): Form<PredictForm>,
83
+ ) -> IndexTemplate {
84
+ let (label, prob) = state.model.predict(&form.text);
85
 
86
  IndexTemplate {
87
  text: form.text,
 
89
  label,
90
  prob: format!("{prob:.2}"),
91
  }),
92
+ metrics: state.metrics.clone(),
93
  }
94
  }
95
 
 
97
  async fn main() -> anyhow::Result<()> {
98
  let model = Model::load(MODEL_PATH.into())
99
  .context("couldn't load {MODEL_PATH} ({e}) - run `cargo run --release -- train` first")?;
100
+ let metrics = Metrics::load(METRICS_PATH.into()).ok().map(MetricsView::from);
101
+ let state = Arc::new(AppState { model, metrics });
102
 
103
  let app = Router::new()
104
  .route("/", get(index))
server/templates/index.html CHANGED
@@ -45,6 +45,18 @@
45
  .result.positive { background: #dcfce7; color: #14532d; }
46
  .result.negative { background: #fee2e2; color: #7f1d1d; }
47
  .prob { font-variant-numeric: tabular-nums; opacity: 0.8; }
 
 
 
 
 
 
 
 
 
 
 
 
48
  </style>
49
  </head>
50
  <body>
@@ -60,5 +72,25 @@
60
  <span class="prob">(p={{ result.prob }})</span>
61
  </div>
62
  {% endif %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  </body>
64
  </html>
 
45
  .result.positive { background: #dcfce7; color: #14532d; }
46
  .result.negative { background: #fee2e2; color: #7f1d1d; }
47
  .prob { font-variant-numeric: tabular-nums; opacity: 0.8; }
48
+ .metrics {
49
+ margin-top: 2rem;
50
+ padding-top: 1rem;
51
+ border-top: 1px solid #ddd;
52
+ display: grid;
53
+ grid-template-columns: repeat(4, 1fr);
54
+ gap: 0.5rem;
55
+ text-align: center;
56
+ }
57
+ .metrics .stat { display: flex; flex-direction: column; }
58
+ .metrics .value { font-size: 1.1rem; font-variant-numeric: tabular-nums; }
59
+ .metrics .label { font-size: 0.75rem; color: #555; text-transform: uppercase; }
60
  </style>
61
  </head>
62
  <body>
 
72
  <span class="prob">(p={{ result.prob }})</span>
73
  </div>
74
  {% endif %}
75
+ {% if let Some(metrics) = metrics %}
76
+ <div class="metrics">
77
+ <div class="stat">
78
+ <span class="value">{{ metrics.accuracy }}%</span>
79
+ <span class="label">Accuracy</span>
80
+ </div>
81
+ <div class="stat">
82
+ <span class="value">{{ metrics.precision }}%</span>
83
+ <span class="label">Precision</span>
84
+ </div>
85
+ <div class="stat">
86
+ <span class="value">{{ metrics.recall }}%</span>
87
+ <span class="label">Recall</span>
88
+ </div>
89
+ <div class="stat">
90
+ <span class="value">{{ metrics.f1 }}%</span>
91
+ <span class="label">F1</span>
92
+ </div>
93
+ </div>
94
+ {% endif %}
95
  </body>
96
  </html>