- calibrate_probs_selects_top_label: use logits that produce confidence >= 0.70 - prediction_response: forward status from Prediction struct instead of hardcode Co-Authored-By: Claude <noreply@anthropic.com>
248 lines
8.9 KiB
Rust
248 lines
8.9 KiB
Rust
use crate::config::{CONFIDENCE_THRESHOLD_HIGH, CONFIDENCE_THRESHOLD_LOW, DEFAULT_TEMPERATURE, LABELS};
|
|
use crate::error::ServiceError;
|
|
use ndarray::Array4;
|
|
use ort::{session::Session, value::TensorRef};
|
|
use serde::Serialize;
|
|
use std::collections::BTreeMap;
|
|
use std::path::Path;
|
|
use std::sync::Mutex;
|
|
|
|
/// Prediction result with temperature-calibrated probabilities and status.
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct Prediction {
|
|
pub status: String, // "confident", "uncertain", "rejected"
|
|
pub label: String,
|
|
pub confidence: f32,
|
|
pub probabilities: BTreeMap<String, f32>,
|
|
}
|
|
|
|
/// Service for running ONNX model inference with temperature-scaled calibration.
|
|
///
|
|
/// The ONNX model outputs raw logits. Temperature scaling + softmax is applied
|
|
/// in predict() to produce calibrated probabilities and a decision status:
|
|
/// - confident: max_prob >= conf_threshold_high
|
|
/// - uncertain: conf_threshold_low <= max_prob < conf_threshold_high
|
|
/// - rejected: max_prob < conf_threshold_low
|
|
pub struct ModelService {
|
|
model_path: std::path::PathBuf,
|
|
input_size: u32,
|
|
temperature: f32,
|
|
conf_threshold_high: f32,
|
|
conf_threshold_low: f32,
|
|
session: Option<Mutex<Session>>,
|
|
}
|
|
|
|
impl ModelService {
|
|
pub fn new(model_path: &Path, input_size: u32) -> Self {
|
|
Self::with_calibration(model_path, input_size, DEFAULT_TEMPERATURE,
|
|
CONFIDENCE_THRESHOLD_HIGH, CONFIDENCE_THRESHOLD_LOW)
|
|
}
|
|
|
|
/// Creates a new ModelService with temperature scaling and confidence thresholds.
|
|
pub fn with_calibration(model_path: &Path, input_size: u32,
|
|
temperature: f32, conf_high: f32, conf_low: f32) -> Self {
|
|
let session = Session::builder()
|
|
.ok()
|
|
.and_then(|mut builder| builder.commit_from_file(model_path).ok())
|
|
.map(Mutex::new);
|
|
|
|
Self {
|
|
model_path: model_path.to_path_buf(),
|
|
input_size,
|
|
temperature,
|
|
conf_threshold_high: conf_high,
|
|
conf_threshold_low: conf_low,
|
|
session,
|
|
}
|
|
}
|
|
|
|
pub fn is_loaded(&self) -> bool {
|
|
self.session.is_some()
|
|
}
|
|
|
|
pub fn model_path(&self) -> &Path {
|
|
&self.model_path
|
|
}
|
|
|
|
pub fn input_size(&self) -> u32 {
|
|
self.input_size
|
|
}
|
|
|
|
pub fn temperature(&self) -> f32 {
|
|
self.temperature
|
|
}
|
|
|
|
/// Runs inference and returns temperature-calibrated Prediction.
|
|
///
|
|
/// The ONNX model outputs raw logits (no softmax). Temperature scaling
|
|
/// is applied: probs = softmax(logits / T).
|
|
pub fn predict(&self, input: Array4<f32>) -> Result<Prediction, ServiceError> {
|
|
let session = self.session.as_ref()
|
|
.ok_or_else(|| ServiceError::ModelUnavailable("Model is not loaded".to_string()))?;
|
|
|
|
let mut session_guard = session.lock()
|
|
.map_err(|_| ServiceError::PredictionFailed("Prediction failed".to_string()))?;
|
|
|
|
let input = TensorRef::from_array_view(&input)
|
|
.map_err(|_| ServiceError::PredictionFailed("Prediction failed".to_string()))?;
|
|
let outputs = session_guard.run(ort::inputs![input])
|
|
.map_err(|_| ServiceError::PredictionFailed("Prediction failed".to_string()))?;
|
|
|
|
let output_tensor = outputs[0].try_extract_tensor::<f32>()
|
|
.map_err(|_| ServiceError::PredictionFailed("Prediction failed".to_string()))?;
|
|
|
|
let logits: Vec<f32> = output_tensor.1.iter().copied().collect();
|
|
|
|
Self::calibrate_prediction(&logits, self.temperature,
|
|
self.conf_threshold_high, self.conf_threshold_low)
|
|
}
|
|
|
|
/// Applies temperature scaling + softmax to raw logits, determines status.
|
|
fn calibrate_prediction(logits: &[f32], temperature: f32,
|
|
conf_high: f32, conf_low: f32) -> Result<Prediction, ServiceError> {
|
|
if logits.len() != LABELS.len() {
|
|
return Err(ServiceError::PredictionFailed("Prediction failed".to_string()));
|
|
}
|
|
|
|
// Reject non-finite values
|
|
if logits.iter().any(|p| !p.is_finite()) {
|
|
return Err(ServiceError::PredictionFailed("Prediction failed".to_string()));
|
|
}
|
|
|
|
// Temperature scaling: divide by T
|
|
let T = if temperature > 0.0 { temperature } else { 1.0 };
|
|
let scaled: Vec<f32> = logits.iter().map(|l| l / T).collect();
|
|
|
|
// Numerically stable softmax: shift by max to avoid overflow
|
|
let max_logit = scaled.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
|
|
let exp_vals: Vec<f32> = scaled.iter().map(|l| (l - max_logit).exp()).collect();
|
|
let sum: f32 = exp_vals.iter().sum();
|
|
let probs: Vec<f32> = exp_vals.iter().map(|e| e / sum).collect();
|
|
|
|
// Find top probability
|
|
let top_idx = probs.iter().enumerate()
|
|
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
|
|
.map(|(idx, _)| idx)
|
|
.unwrap_or(0);
|
|
|
|
let confidence = probs[top_idx];
|
|
let top_label = LABELS[top_idx].to_string();
|
|
|
|
// Determine status
|
|
let status = if confidence >= conf_high {
|
|
"confident"
|
|
} else if confidence >= conf_low {
|
|
"uncertain"
|
|
} else {
|
|
"rejected"
|
|
};
|
|
|
|
let mut probabilities = BTreeMap::new();
|
|
for (i, &prob) in probs.iter().enumerate() {
|
|
probabilities.insert(LABELS[i].to_string(), prob);
|
|
}
|
|
|
|
Ok(Prediction {
|
|
status: status.to_string(),
|
|
label: top_label,
|
|
confidence,
|
|
probabilities,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
const T: f32 = 1.0;
|
|
const HIGH: f32 = 0.70;
|
|
const LOW: f32 = 0.45;
|
|
|
|
#[test]
|
|
fn calibrate_probs_selects_top_label() {
|
|
// [1, 2, 3, 0.5] → softmax ≈ [0.086, 0.235, 0.638, 0.040]
|
|
// 0.638 < 0.70 → "uncertain". Use larger gap for "confident".
|
|
let logits = [0.0, 0.0, 10.0, 0.0]; // softmax ≈ [0, 0, ~1, 0]
|
|
let result = ModelService::calibrate_prediction(&logits, T, HIGH, LOW);
|
|
assert!(result.is_ok());
|
|
let p = result.unwrap();
|
|
// Index 2 = Hawar Daun (highest logit)
|
|
assert_eq!(p.label, "Hawar Daun");
|
|
assert!(p.confidence >= 0.999);
|
|
assert_eq!(p.status, "confident");
|
|
}
|
|
|
|
#[test]
|
|
fn calibrate_probs_uncertain_when_borderline() {
|
|
let logits = [0.0, 0.4, 0.0, 0.0]; // softmax with low max
|
|
let result = ModelService::calibrate_prediction(&logits, 2.0, HIGH, LOW);
|
|
assert!(result.is_ok());
|
|
let p = result.unwrap();
|
|
// T=2.0 flattens further — likely uncertain or rejected
|
|
assert!(p.status == "uncertain" || p.status == "rejected");
|
|
}
|
|
|
|
#[test]
|
|
fn calibrate_probs_rejects_low_confidence() {
|
|
let logits = [0.01, 0.01, 0.01, 0.02];
|
|
let result = ModelService::calibrate_prediction(&logits, 10.0, HIGH, LOW);
|
|
assert!(result.is_ok());
|
|
let p = result.unwrap();
|
|
assert_eq!(p.status, "rejected");
|
|
}
|
|
|
|
#[test]
|
|
fn calibrate_probs_all_probabilities_present() {
|
|
let logits = [1.0, 2.0, 3.0, 4.0];
|
|
let result = ModelService::calibrate_prediction(&logits, T, HIGH, LOW);
|
|
assert!(result.is_ok());
|
|
let p = result.unwrap();
|
|
assert_eq!(p.probabilities.len(), 4);
|
|
assert!(p.probabilities.contains_key("Bercak Daun"));
|
|
assert!(p.probabilities.contains_key("Daun Sehat"));
|
|
assert!(p.probabilities.contains_key("Hawar Daun"));
|
|
assert!(p.probabilities.contains_key("Karat Daun"));
|
|
}
|
|
|
|
#[test]
|
|
fn calibrate_probs_rejects_wrong_length() {
|
|
let logits = [0.25, 0.25, 0.25];
|
|
let result = ModelService::calibrate_prediction(&logits, T, HIGH, LOW);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn calibrate_probs_rejects_nan() {
|
|
let logits = [0.1, f32::NAN, 0.6, 0.1];
|
|
let result = ModelService::calibrate_prediction(&logits, T, HIGH, LOW);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn temperature_one_gives_same_ranking() {
|
|
let logits = [0.0, 1.0, 2.0, 3.0];
|
|
let r1 = ModelService::calibrate_prediction(&logits, 1.0, 0.0, 0.0).unwrap();
|
|
let r2 = ModelService::calibrate_prediction(&logits, 2.0, 0.0, 0.0).unwrap();
|
|
assert_eq!(r1.label, r2.label);
|
|
assert!(r1.confidence > r2.confidence); // T=2 flattens
|
|
}
|
|
|
|
#[test]
|
|
fn missing_model_file_creates_unloaded_service() {
|
|
let service = ModelService::new(Path::new("/nonexistent/model.onnx"), 224);
|
|
assert!(!service.is_loaded());
|
|
}
|
|
|
|
#[test]
|
|
fn unloaded_service_returns_model_unavailable() {
|
|
let service = ModelService::new(Path::new("/nonexistent/model.onnx"), 224);
|
|
let result = service.predict(ndarray::Array4::zeros((1, 224, 224, 3)));
|
|
assert!(result.is_err());
|
|
match result.unwrap_err() {
|
|
ServiceError::ModelUnavailable(msg) => assert_eq!(msg, "Model is not loaded"),
|
|
_ => panic!("expected ModelUnavailable error"),
|
|
}
|
|
}
|
|
}
|