Spaces:
Sleeping
Sleeping
feat: add metrics
Browse files- model/src/bin/main.rs +2 -0
- model/src/lib.rs +1 -0
- model/src/model.rs +16 -11
- model/src/model/metrics.rs +111 -0
- server/src/main.rs +37 -5
- server/templates/index.html +32 -0
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
|
| 135 |
-
|
| 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 |
-
"\
|
| 143 |
-
accuracy * 100.0,
|
| 144 |
-
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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>
|