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}