Skip to main content

sokuto_runtime/hierarchical/
calibration.rs

1//! # 階層較正・確率結合モジュール
2//!
3//! 大分類エントロピー連動の温度スケーリング、ODIR 正則化 Dirichlet 較正、
4//! および全候補空間への確率復元・周辺化結合を提供する。
5
6use indexmap::IndexMap;
7use serde::{Deserialize, Serialize};
8
9use crate::error::{Result, RuntimeError};
10
11/// 確率計算および対数変換時のアンダーフロー防止用微小量。
12pub const EPSILON: f64 = 1e-12;
13
14/// デフォルトのエントロピー連動温度係数 ($\gamma$)。
15pub const DEFAULT_ENTROPY_TEMP_GAMMA: f64 = 0.5;
16
17/// デフォルトの大分類確率減衰べき乗指数 ($\alpha$)。
18pub const DEFAULT_COARSE_POWER_ALPHA: f64 = 0.85;
19
20/// ディリクレ較正器 (Dirichlet Calibrator)。
21///
22/// Kull et al. (NeurIPS 2019) の Dirichlet 較正に基づく。
23/// 対数確率ベクトル $\ln \mathbf{p}$ に対するアフィン変換 $\mathbf{z} = W \ln \mathbf{p} + \mathbf{b}$ を行い、
24/// Softmax を適用して適正較正された確率分布を出力する。
25#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
26pub struct DirichletCalibrator {
27    /// クラス数 $K$。
28    pub num_classes: usize,
29    /// 重み行列 $W \in \mathbb{R}^{K \times K}$ (行優先フラット配列)。
30    pub weights: Vec<f64>,
31    /// バイアスベクトル $\mathbf{b} \in \mathbb{R}^K$。
32    pub bias: Vec<f64>,
33}
34
35impl DirichletCalibrator {
36    /// 恒等変換を行う較正器 (未較正時) を生成する。
37    ///
38    /// $W = I, \mathbf{b} = \mathbf{0}$ となり、入力確率分布がそのまま維持される。
39    pub fn identity(num_classes: usize) -> Self {
40        let mut weights = vec![0.0; num_classes * num_classes];
41        for i in 0..num_classes {
42            weights[i * num_classes + i] = 1.0;
43        }
44        let bias = vec![0.0; num_classes];
45        Self {
46            num_classes,
47            weights,
48            bias,
49        }
50    }
51
52    /// 事前学習されたパラメータを指定して較正器を構築する。
53    ///
54    /// # 引数
55    /// - `num_classes`: 分類クラス数 $K$。
56    /// - `weights`: $K \times K$ の行優先重み行列。
57    /// - `bias`: 長さ $K$ のバイアスベクトル。
58    pub fn new(num_classes: usize, weights: Vec<f64>, bias: Vec<f64>) -> Result<Self> {
59        if weights.len() != num_classes * num_classes {
60            return Err(RuntimeError::InvalidQuestion(format!(
61                "Dirichlet較正重み行列のサイズ ({}) が想定 ({}x{}={}) と一致しません。",
62                weights.len(),
63                num_classes,
64                num_classes,
65                num_classes * num_classes
66            )));
67        }
68        if bias.len() != num_classes {
69            return Err(RuntimeError::InvalidQuestion(format!(
70                "Dirichlet較正バイアスベクトルの長さ ({}) が想定 ({}) と一致しません。",
71                bias.len(),
72                num_classes
73            )));
74        }
75        Ok(Self {
76            num_classes,
77            weights,
78            bias,
79        })
80    }
81
82    /// 未較正の確率ベクトルを較正済み確率ベクトルへ変換する。
83    ///
84    /// 入力確率値は $\epsilon$ でクリップされ、対数空間でのアフィン変換後に Softmax で正規化される。
85    pub fn calibrate(&self, probs: &[f64]) -> Result<Vec<f64>> {
86        if probs.len() != self.num_classes {
87            return Err(RuntimeError::InvalidQuestion(format!(
88                "入力確率ベクトルの長さ ({}) が較正器のクラス数 ({}) と一致しません。",
89                probs.len(),
90                self.num_classes
91            )));
92        }
93
94        let k = self.num_classes;
95        let mut log_p = Vec::with_capacity(k);
96        for &p in probs {
97            let clamped = p.max(EPSILON);
98            log_p.push(clamped.ln());
99        }
100
101        let mut logits = vec![0.0; k];
102        for (i, logit) in logits.iter_mut().enumerate() {
103            let mut sum = self.bias[i];
104            let row_offset = i * k;
105            for (j, &lp) in log_p.iter().enumerate() {
106                sum += self.weights[row_offset + j] * lp;
107            }
108            *logit = sum;
109        }
110
111        Ok(softmax_f64(&logits))
112    }
113}
114
115/// 確率分布からシャノンエントロピーおよび正規化エントロピー $\tilde{H} \in [0, 1]$ を計算する。
116///
117/// # 引数
118/// - `probs`: 確率スライス (総和がおおむね 1.0 であること)。
119///
120/// # 戻り値
121/// `(shannon_entropy, normalized_entropy)` のタプル。
122pub fn compute_normalized_entropy(probs: &[f64]) -> (f64, f64) {
123    let k = probs.len();
124    if k <= 1 {
125        return (0.0, 0.0);
126    }
127
128    let mut entropy = 0.0;
129    for &p in probs {
130        if p > EPSILON {
131            entropy -= p * p.ln();
132        }
133    }
134
135    let max_entropy = (k as f64).ln();
136    let normalized = if max_entropy > EPSILON {
137        (entropy / max_entropy).clamp(0.0, 1.0)
138    } else {
139        0.0
140    };
141
142    (entropy, normalized)
143}
144
145/// 大分類の正規化エントロピーに基づいて、第 2 パス (細分類) に適用する動的温度を算出する。
146///
147/// 数式:
148/// $$T_{\text{fine}} = 1.0 + \gamma \cdot \tilde{H}(\mathbf{P}_C)$$
149///
150/// 大分類で迷いが生じている (エントロピーが高い) ほど温度を引き上げ、細分類の過信 Softmax を平滑化する。
151pub fn compute_fine_temperature(normalized_entropy: f64, gamma: f64) -> f64 {
152    (1.0 + gamma * normalized_entropy).max(1.0)
153}
154
155/// 細分類のロジットに温度スケーリングを適用した上で Softmax を計算する。
156///
157/// # 引数
158/// - `logits`: 第 2 パスで得られた未正規化ロジット。
159/// - `temperature`: スケーリング温度 ($T \ge 1.0$)。
160pub fn temperature_scaled_softmax(logits: &[f64], temperature: f64) -> Vec<f64> {
161    let temp = temperature.max(0.01);
162    let mut scaled = Vec::with_capacity(logits.len());
163    for &l in logits {
164        scaled.push(l / temp);
165    }
166    softmax_f64(&scaled)
167}
168
169/// ロジット列に対する数値的に安定な Softmax 計算。
170pub fn softmax_f64(logits: &[f64]) -> Vec<f64> {
171    if logits.is_empty() {
172        return Vec::new();
173    }
174
175    let max_logit = logits
176        .iter()
177        .copied()
178        .fold(f64::NEG_INFINITY, |a, b| if a > b { a } else { b });
179
180    let mut exps = Vec::with_capacity(logits.len());
181    let mut sum = 0.0;
182    for &l in logits {
183        let e = (l - max_logit).exp();
184        exps.push(e);
185        sum += e;
186    }
187
188    if sum <= 0.0 || !sum.is_finite() {
189        let uniform = 1.0 / (logits.len() as f64);
190        return vec![uniform; logits.len()];
191    }
192
193    for e in &mut exps {
194        *e /= sum;
195    }
196    exps
197}
198
199/// 第 1 パス (大分類) 確率と第 2 パス (細分類) 確率を減衰周辺化結合し、全候補空間の確率分布を復元する。
200///
201/// # アルゴリズム
202/// 1. 各細分類候補 $F_k$ に対し、所属する親大分類 $C_j$ の確率 $P(C_j)$ を取得する。
203/// 2. 減衰べき乗指数 $\alpha$ を用いて不確実性を伝播させた結合スコアを算出:
204///    $$S(F_k) = P(C_j)^\alpha \cdot P(F_k \mid C_j)$$
205/// 3. 第 2 パスに含まれる全候補で正規化 (周辺化)。
206/// 4. 評価対象外となった除外クラスタの候補には `0.0` を設定し、全体の確率和を 1.0 に正規化する。
207///
208/// # 引数
209/// - `all_fine_keys`: 全細分類キーの完全リスト (順序維持)。
210/// - `fine_to_coarse`: 細分類キーから親大分類キーへの逆引きマップ。
211/// - `coarse_probs`: 大分類キーとその確率のマッピング。
212/// - `evaluated_fine_probs`: 第 2 パスで実際に評価された細分類キーとその確率 (温度補正後)。
213/// - `coarse_alpha`: 大分類確率の減衰指数 ($\alpha \in [0.8, 1.0]$)。
214///
215/// # 戻り値
216/// 全細分類キーに対応する確率マップ (`IndexMap<String, f64>`)。総和は厳密に 1.0 となる。
217pub fn combine_and_reconstruct_probabilities(
218    all_fine_keys: &[String],
219    fine_to_coarse: &IndexMap<String, String>,
220    coarse_probs: &IndexMap<String, f64>,
221    evaluated_fine_probs: &IndexMap<String, f64>,
222    coarse_alpha: f64,
223) -> Result<IndexMap<String, f64>> {
224    if all_fine_keys.is_empty() {
225        return Err(RuntimeError::InvalidQuestion(
226            "all_fine_keys が空です。".to_string(),
227        ));
228    }
229    if evaluated_fine_probs.is_empty() {
230        return Err(RuntimeError::InvalidQuestion(
231            "evaluated_fine_probs が空です。".to_string(),
232        ));
233    }
234
235    // 1. 第 2 パス評価対象候補の結合未正規化スコアを計算
236    let mut raw_scores = IndexMap::with_capacity(evaluated_fine_probs.len());
237    let mut total_score = 0.0;
238
239    for (fine_key, &fine_prob) in evaluated_fine_probs {
240        let coarse_key = fine_to_coarse.get(fine_key).ok_or_else(|| {
241            RuntimeError::InvalidQuestion(format!(
242                "細分類 `{}` の親大分類が見つかりません。",
243                fine_key
244            ))
245        })?;
246
247        let coarse_prob = coarse_probs.get(coarse_key).copied().unwrap_or(0.0);
248        let attenuated_coarse = coarse_prob.max(EPSILON).powf(coarse_alpha);
249
250        let combined = attenuated_coarse * fine_prob.max(0.0);
251        raw_scores.insert(fine_key.clone(), combined);
252        total_score += combined;
253    }
254
255    // 2. 正規化
256    let mut normalized_evaluated = IndexMap::with_capacity(evaluated_fine_probs.len());
257    if total_score > EPSILON && total_score.is_finite() {
258        for (k, s) in raw_scores {
259            normalized_evaluated.insert(k, s / total_score);
260        }
261    } else {
262        // フォールバック: 一様分布
263        let uniform = 1.0 / (evaluated_fine_probs.len() as f64);
264        for k in evaluated_fine_probs.keys() {
265            normalized_evaluated.insert(k.clone(), uniform);
266        }
267    }
268
269    // 3. 全候補空間への再構成 (除外候補には 0.0 を設定)
270    let mut full_probs = IndexMap::with_capacity(all_fine_keys.len());
271    let mut final_sum = 0.0;
272
273    for key in all_fine_keys {
274        let prob = normalized_evaluated.get(key).copied().unwrap_or(0.0);
275        full_probs.insert(key.clone(), prob);
276        final_sum += prob;
277    }
278
279    // 厳密な 1.0 への再正規化
280    if (final_sum - 1.0).abs() > 1e-6 && final_sum > 0.0 {
281        for p in full_probs.values_mut() {
282            *p /= final_sum;
283        }
284    }
285
286    Ok(full_probs)
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    #[test]
294    fn test_identity_calibrator() {
295        let cal = DirichletCalibrator::identity(3);
296        let probs = vec![0.6, 0.3, 0.1];
297        let calibrated = cal.calibrate(&probs).unwrap();
298
299        for (a, b) in probs.iter().zip(calibrated.iter()) {
300            assert!((a - b).abs() < 1e-4);
301        }
302    }
303
304    #[test]
305    fn test_entropy_computation() {
306        // 確信分布 (エントロピー最小)
307        let certain = vec![1.0, 0.0, 0.0];
308        let (_, norm_certain) = compute_normalized_entropy(&certain);
309        assert!(norm_certain < 1e-5);
310
311        // 一様分布 (エントロピー最大)
312        let uniform = vec![1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0];
313        let (_, norm_uniform) = compute_normalized_entropy(&uniform);
314        assert!((norm_uniform - 1.0).abs() < 1e-4);
315    }
316
317    #[test]
318    fn test_fine_temperature() {
319        let temp_certain = compute_fine_temperature(0.0, 0.5);
320        assert_eq!(temp_certain, 1.0);
321
322        let temp_uncertain = compute_fine_temperature(1.0, 0.5);
323        assert_eq!(temp_uncertain, 1.5);
324    }
325
326    #[test]
327    fn test_combine_and_reconstruct() {
328        let all_keys = vec![
329            "c1_f1".to_string(),
330            "c1_f2".to_string(),
331            "c2_f1".to_string(),
332            "c2_f2".to_string(),
333            "c3_f1".to_string(),
334        ];
335
336        let mut fine_to_coarse = IndexMap::new();
337        fine_to_coarse.insert("c1_f1".to_string(), "c1".to_string());
338        fine_to_coarse.insert("c1_f2".to_string(), "c1".to_string());
339        fine_to_coarse.insert("c2_f1".to_string(), "c2".to_string());
340        fine_to_coarse.insert("c2_f2".to_string(), "c2".to_string());
341        fine_to_coarse.insert("c3_f1".to_string(), "c3".to_string());
342
343        let mut coarse_probs = IndexMap::new();
344        coarse_probs.insert("c1".to_string(), 0.7);
345        coarse_probs.insert("c2".to_string(), 0.25);
346        coarse_probs.insert("c3".to_string(), 0.05);
347
348        // 第 2 パスでは c1_f1 と c1_f2 のみが評価されたケース
349        let mut evaluated = IndexMap::new();
350        evaluated.insert("c1_f1".to_string(), 0.8);
351        evaluated.insert("c1_f2".to_string(), 0.2);
352
353        let full = combine_and_reconstruct_probabilities(
354            &all_keys,
355            &fine_to_coarse,
356            &coarse_probs,
357            &evaluated,
358            0.85,
359        )
360        .unwrap();
361
362        assert_eq!(full.len(), 5);
363        // 評価外は 0.0
364        assert_eq!(full.get("c2_f1").copied().unwrap(), 0.0);
365        assert_eq!(full.get("c2_f2").copied().unwrap(), 0.0);
366        assert_eq!(full.get("c3_f1").copied().unwrap(), 0.0);
367
368        // 評価対象は正の値
369        assert!(full.get("c1_f1").copied().unwrap() > full.get("c1_f2").copied().unwrap());
370
371        // 総和が 1.0
372        let sum: f64 = full.values().sum();
373        assert!((sum - 1.0).abs() < 1e-6);
374    }
375}