kuva 0.2.0

Scientific plotting library in Rust with various backends.
Documentation
# Precision-Recall Curve

A Precision-Recall (PR) curve plots precision (positive predictive value) against recall (sensitivity) as the classification threshold is varied. Unlike a ROC curve, the PR curve is insensitive to the class imbalance ratio and focuses exclusively on the performance over the positive class — making it the correct choice for imbalanced datasets such as rare disease detection, fraud detection, or information retrieval.

The area under the PR curve (AUC-PR) summarises classifier performance; a perfect classifier achieves AUC-PR = 1.0, while the no-skill baseline is a horizontal line at the prevalence (positive rate).

**Import path:** `kuva::plot::pr::{PrPlot, PrGroup}`

---

## Basic usage

Build one `PrGroup` per classifier using `.with_raw()`, which accepts `(score, bool)` pairs. The curve and AUC-PR are computed internally.

```rust,no_run
use kuva::plot::pr::{PrPlot, PrGroup};
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

/// Generate deterministic imbalanced predictions.
/// 1 positive per `ratio` negatives; positive scores drawn higher.
fn imbalanced_data(n: usize, ratio: usize, signal: f64) -> Vec<(f64, bool)> {
    let mut data = Vec::with_capacity(n);
    for i in 0..n {
        let is_pos = i % ratio == 0;
        let frac = i as f64 / n as f64;
        let score = if is_pos { signal + (1.0 - signal) * frac } else { (1.0 - signal) * frac };
        data.push((score.clamp(0.0, 1.0), is_pos));
    }
    data
}

let group = PrGroup::new("Classifier A")
    .with_raw(imbalanced_data(300, 10, 0.7));

let pr = PrPlot::new().with_group(group);

let plots = vec![Plot::Pr(pr)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Precision-Recall Curve")
    .with_x_label("Recall")
    .with_y_label("Precision");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
std::fs::write("pr.svg", svg).unwrap();
```

<img src="../assets/pr/basic.svg" alt="Basic precision-recall curve" width="560">

The dashed grey horizontal line is the no-skill baseline at the dataset prevalence. The AUC-PR appears in the legend. Both can be suppressed — see the API reference.

---

## Optimal F1 threshold marker

`.with_optimal_point()` marks the threshold that maximises the F1 score (harmonic mean of precision and recall). This is the natural operating point when precision and recall have equal importance.

```rust,no_run
use kuva::plot::pr::{PrPlot, PrGroup};
use kuva::render::plots::Plot;
# use kuva::render::layout::Layout;
# use kuva::render::render::render_multiple;
# fn imbalanced_data(n: usize, ratio: usize, signal: f64) -> Vec<(f64, bool)> { vec![] }

let group = PrGroup::new("Classifier A")
    .with_raw(imbalanced_data(300, 10, 0.7))
    .with_optimal_point();

let pr = PrPlot::new().with_group(group);
let plots = vec![Plot::Pr(pr)];
```

<img src="../assets/pr/optimal.svg" alt="PR curve with optimal F1 threshold marker" width="560">

---

## Multi-model comparison

Add one `PrGroup` per model. Use `.with_legend()` on `PrPlot` to show a legend.

```rust,no_run
use kuva::plot::pr::{PrPlot, PrGroup};
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

# fn imbalanced_data(n: usize, ratio: usize, signal: f64) -> Vec<(f64, bool)> { vec![] }
let g1 = PrGroup::new("Logistic Regression")
    .with_raw(imbalanced_data(300, 10, 0.65));

let g2 = PrGroup::new("Random Forest")
    .with_raw(imbalanced_data(300, 10, 0.80))
    .with_optimal_point();

let g3 = PrGroup::new("Neural Network")
    .with_raw(imbalanced_data(300, 10, 0.88))
    .with_optimal_point();

let pr = PrPlot::new()
    .with_group(g1)
    .with_group(g2)
    .with_group(g3)
    .with_legend("Model");

let plots = vec![Plot::Pr(pr)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Model Comparison — Rare Event Detection")
    .with_x_label("Recall")
    .with_y_label("Precision");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
```

<img src="../assets/pr/multi_model.svg" alt="Three-model PR comparison" width="560">

---

## Pre-computed (recall, precision) points

If you have already computed the PR curve externally (e.g. from Python's `sklearn.metrics.precision_recall_curve`), pass the points directly. Supply `.with_prevalence()` to draw the correct no-skill baseline.

```rust,no_run
use kuva::plot::pr::{PrPlot, PrGroup};
use kuva::render::plots::Plot;

// (recall, precision) already computed externally; sorted by increasing recall
let points = vec![
    (0.00, 1.00),
    (0.10, 0.91),
    (0.25, 0.84),
    (0.40, 0.76),
    (0.55, 0.67),
    (0.70, 0.55),
    (0.85, 0.42),
    (1.00, 0.10),
];

let group = PrGroup::new("External model")
    .with_points(points)
    .with_prevalence(0.10);  // 10% positive rate

let pr = PrPlot::new().with_group(group);
let plots = vec![Plot::Pr(pr)];
```

---

## Line style

Differentiate models in greyscale or colorblind-safe output using custom stroke styles.

```rust,no_run
use kuva::plot::pr::{PrPlot, PrGroup};
use kuva::render::plots::Plot;
# fn imbalanced_data(n: usize, ratio: usize, signal: f64) -> Vec<(f64, bool)> { vec![] }

let g1 = PrGroup::new("Model A")
    .with_raw(imbalanced_data(300, 8, 0.75))
    .with_color("black")
    .with_line_width(2.0);

let g2 = PrGroup::new("Model B")
    .with_raw(imbalanced_data(300, 8, 0.60))
    .with_color("black")
    .with_line_width(1.5)
    .with_dasharray("6 3");

let pr = PrPlot::new().with_group(g1).with_group(g2).with_legend("Model");
let plots = vec![Plot::Pr(pr)];
```

---

## PrPlot API reference

### `PrPlot` builders

| Method | Default | Description |
|--------|---------|-------------|
| `PrPlot::new()` || Create a new PR curve plot |
| `.with_group(PrGroup)` || Add one classifier group |
| `.with_groups(iter)` || Add multiple groups at once |
| `.with_color(css)` | `"steelblue"` | Fallback color when groups have no explicit color |
| `.with_baseline(bool)` | `true` | Show the no-skill prevalence baseline |
| `.with_legend(label)` || Legend title (shown when groups have labels) |

### `PrGroup` builders

| Method | Default | Description |
|--------|---------|-------------|
| `PrGroup::new(label)` || Create a group with a display label |
| `.with_raw(iter)` || Raw `(score: f64, label: bool)` predictions; computes curve and AUC internally |
| `.with_points(iter)` || Pre-computed `(recall, precision)` points; trapezoidal AUC only |
| `.with_prevalence(f)` || Override prevalence for the no-skill baseline (pre-computed input) |
| `.with_color(css)` | palette | Curve color |
| `.with_optimal_point()` || Mark the F1-optimal threshold |
| `.with_auc_label(bool)` | `true` | Append `AUC = …` to the legend entry |
| `.with_line_width(px)` | `2.0` | Curve stroke width |
| `.with_dasharray(s)` || SVG stroke-dasharray string (e.g. `"6 3"`) |