Skip to main content

hanzo_onnx/
value.rs

1//! What sits on a graph edge.
2//!
3//! ONNX has three value kinds — `tensor`, `seq` and `map` — and `ai.onnx` produces
4//! only the first. That is why this module used to be the single line
5//! `pub type Value = Tensor;`, and why the classical domain could not be read: the
6//! `ai.onnx.ml` operators that scikit-learn, XGBoost and LightGBM actually export
7//! produce the other two.
8//!
9//! * `ZipMap` — on the probability output of EVERY default classifier export —
10//!   produces `seq(map(K, tensor(float)))`.
11//! * A classifier fitted on string labels reports a `tensor(string)`, and
12//!   `LabelEncoder`'s whole job is to move between text and numbers.
13//!
14//! So the value type is the sum it always was, and each kind is modelled as the thing
15//! it is rather than as a tensor with a convention layered on top.
16
17use hanzo_ml::{bail, DType, Device, IndexOp, Result, Tensor};
18
19/// A value on a graph edge.
20#[derive(Debug, Clone)]
21pub enum Value {
22    /// Numbers with a shape — everything `ai.onnx` reads and writes.
23    Tensor(Tensor),
24    /// `tensor(string)`.
25    Text(Text),
26    /// `seq(map(K, tensor(float)))` — scores under the labels they belong to.
27    Table(Table),
28}
29
30impl Value {
31    /// Which kind this is, for an error that names what it found.
32    pub fn kind(&self) -> &'static str {
33        match self {
34            Self::Tensor(_) => "a tensor",
35            Self::Text(_) => "a tensor(string)",
36            Self::Table(_) => "a sequence of maps",
37        }
38    }
39
40    /// The tensor this value is.
41    ///
42    /// The one place the tensor-only operators of `ai.onnx` meet the wider value type:
43    /// an operator that cannot work on text or on a table says so by calling this and
44    /// gets an error naming what arrived instead.
45    pub fn tensor(&self) -> Result<&Tensor> {
46        match self {
47            Self::Tensor(t) => Ok(t),
48            other => bail!("expected a tensor, got {}", other.kind()),
49        }
50    }
51
52    /// The text this value is.
53    pub fn text(&self) -> Result<&Text> {
54        match self {
55            Self::Text(t) => Ok(t),
56            other => bail!("expected a tensor(string), got {}", other.kind()),
57        }
58    }
59
60    /// The table this value is.
61    pub fn table(&self) -> Result<&Table> {
62        match self {
63            Self::Table(t) => Ok(t),
64            other => bail!("expected a sequence of maps, got {}", other.kind()),
65        }
66    }
67
68    /// The tensor this value is, taking ownership.
69    pub fn into_tensor(self) -> Result<Tensor> {
70        match self {
71            Self::Tensor(t) => Ok(t),
72            other => bail!("expected a tensor, got {}", other.kind()),
73        }
74    }
75}
76
77impl From<Tensor> for Value {
78    fn from(t: Tensor) -> Self {
79        Self::Tensor(t)
80    }
81}
82
83impl From<Text> for Value {
84    fn from(t: Text) -> Self {
85        Self::Text(t)
86    }
87}
88
89impl From<Table> for Value {
90    fn from(t: Table) -> Self {
91        Self::Table(t)
92    }
93}
94
95/// `tensor(string)`: elements, and the shape they lie in.
96///
97/// Not a [`Tensor`]: `hanzo_ml::DType` has no string element type, and inventing one
98/// would put a variable-length heap value inside a type whose whole contract is a flat
99/// numeric buffer a GPU can address. Text is a different kind of value, so it is a
100/// different type.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct Text {
103    dims: Vec<usize>,
104    data: Vec<String>,
105}
106
107impl Text {
108    /// Text of this shape. Fails when the shape does not account for every element.
109    pub fn new(data: Vec<String>, dims: impl Into<Vec<usize>>) -> Result<Self> {
110        let dims = dims.into();
111        let count: usize = dims.iter().product();
112        if count != data.len() {
113            bail!(
114                "a tensor(string) of shape {dims:?} holds {count} elements, but {} were given",
115                data.len()
116            );
117        }
118        Ok(Self { dims, data })
119    }
120
121    /// Rank-1 text — the shape every classical operator that emits text produces.
122    pub fn vector(data: Vec<String>) -> Self {
123        let dims = vec![data.len()];
124        Self { dims, data }
125    }
126
127    /// This value's shape.
128    pub fn dims(&self) -> &[usize] {
129        &self.dims
130    }
131
132    /// The elements, in row-major order.
133    pub fn elements(&self) -> &[String] {
134        &self.data
135    }
136}
137
138/// A model's own labels: integers or text, never both and never neither.
139///
140/// ONNX writes a classifier's classes into two mutually exclusive attributes —
141/// `classlabels_int64s` and `classlabels_strings` — and `ZipMap` keys its maps the same
142/// two ways. A reader holding two vectors would admit a node that declared both, or
143/// neither; a sum admits exactly one, which is the number a classifier has.
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub enum Labels {
146    /// Integer labels.
147    Ints(Vec<i64>),
148    /// Text labels.
149    Text(Vec<String>),
150}
151
152impl Labels {
153    /// How many labels there are — the width of a score row.
154    pub fn len(&self) -> usize {
155        match self {
156            Self::Ints(v) => v.len(),
157            Self::Text(v) => v.len(),
158        }
159    }
160
161    /// Whether there are none, which no fitted classifier has.
162    pub fn is_empty(&self) -> bool {
163        self.len() == 0
164    }
165
166    /// The labels at these positions, as the value a graph reports.
167    pub fn at(&self, positions: &[usize], device: &Device) -> Result<Value> {
168        match self {
169            Self::Ints(v) => {
170                let picked: Vec<i64> = positions.iter().map(|&k| v[k]).collect();
171                let n = picked.len();
172                Ok(Tensor::from_vec(picked, n, device)?.into())
173            }
174            Self::Text(v) => {
175                Ok(Text::vector(positions.iter().map(|&k| v[k].clone()).collect()).into())
176            }
177        }
178    }
179}
180
181/// `seq(map(K, tensor(float)))` as the value it is: one score per row per label.
182///
183/// `ZipMap` zips ONE label list against every row of a score matrix, so every map in
184/// the sequence carries the SAME keys — that is the operator's entire contract.
185/// `Vec<HashMap<K, f32>>` would admit a sequence whose maps disagree about their keys,
186/// a state `ZipMap` cannot produce and no reader should have to consider. Keeping the
187/// scores as one `(rows, labels)` tensor also means the numbers are not copied out of
188/// the layout every other operator reads them in.
189#[derive(Debug, Clone)]
190pub struct Table {
191    labels: Labels,
192    scores: Tensor,
193}
194
195impl Table {
196    /// A table of scores under these labels.
197    ///
198    /// Fails unless `scores` is rank-2 with one column per label — the shape a
199    /// classifier's score output has, and the only shape a label list can name.
200    pub fn new(labels: Labels, scores: Tensor) -> Result<Self> {
201        if scores.rank() != 2 {
202            bail!(
203                "a table's scores are one row per sample and one column per label, so rank 2; \
204                 got rank {}",
205                scores.rank()
206            );
207        }
208        let columns = scores.dim(1)?;
209        if columns != labels.len() {
210            bail!(
211                "a table has {} labels but its scores have {columns} columns",
212                labels.len()
213            );
214        }
215        Ok(Self { labels, scores })
216    }
217
218    /// The labels its columns are under.
219    pub fn labels(&self) -> &Labels {
220        &self.labels
221    }
222
223    /// The scores, one row per sample.
224    pub fn scores(&self) -> &Tensor {
225        &self.scores
226    }
227
228    /// How many samples were scored.
229    pub fn rows(&self) -> usize {
230        self.scores.dims()[0]
231    }
232
233    /// One row as `(label, score)` pairs, in the model's own label order.
234    ///
235    /// The dictionary form a caller reading ONNX's `seq(map(...))` expects. Built on
236    /// demand rather than stored, because the scores are already here in a layout that
237    /// answers every other question too.
238    pub fn row(&self, at: usize) -> Result<Vec<(Key, f32)>> {
239        if at >= self.rows() {
240            bail!("row {at} of a {}-row table", self.rows());
241        }
242        let scores = self.scores.i(at)?.to_dtype(DType::F32)?.to_vec1::<f32>()?;
243        Ok(match &self.labels {
244            Labels::Ints(v) => v.iter().copied().map(Key::Int).zip(scores).collect(),
245            Labels::Text(v) => v.iter().cloned().map(Key::Text).zip(scores).collect(),
246        })
247    }
248}
249
250/// One map key: whichever of the two kinds its table is labelled with.
251#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
252pub enum Key {
253    /// An integer key.
254    Int(i64),
255    /// A text key.
256    Text(String),
257}