Skip to main content

optirs_core/streaming/adaptive_streaming/
meta_transfer.rs

1// Transfer learning across streaming optimization tasks.
2//
3// Split out of `meta_learning.rs` so that file stays under the 2000-line policy
4// limit.
5//
6// This module implements *instance transfer*: experiences gathered on one stream
7// (a "source domain") are replayed into the meta-learner training a different
8// stream (the "target domain"), weighted by how similar the two domains look.
9// That is the one transfer family this crate has the material for — it owns
10// stored `MetaExperience` values but not the model weights or feature
11// extractors the other `TransferStrategy` variants would need.
12//
13// What changed: `TransferLearning::new` used to fabricate its own report card —
14// `success_rate: 0.5`, `improvement: 0.1`, `efficiency: 0.7`,
15// `domain_similarity: 0.5` — while `source_experiences` was never written and
16// nothing ever read any of it. Every metric here is now either measured or
17// absent, and the struct only reports a domain similarity it actually computed.
18
19use super::meta_learning::MetaExperience;
20use scirs2_core::numeric::Float;
21use std::collections::HashMap;
22
23/// Transfer learning strategies.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum TransferStrategy {
26    /// Direct parameter transfer: copy the source model's parameters.
27    ParameterTransfer,
28    /// Feature transfer: reuse the source feature representation.
29    FeatureTransfer,
30    /// Instance transfer: replay source-domain experiences into the target.
31    InstanceTransfer,
32    /// Relational transfer: carry over relations between entities.
33    RelationalTransfer,
34    /// Meta-transfer learning: transfer the learning procedure itself.
35    MetaTransfer,
36}
37
38impl TransferStrategy {
39    /// Whether this crate can carry out the strategy.
40    ///
41    /// Only [`TransferStrategy::InstanceTransfer`] is supported: the meta-learner
42    /// stores experiences, not model weights, feature extractors or relational
43    /// structure, so the other four have nothing here to transfer. Reporting
44    /// that plainly is better than accepting the setting and quietly doing
45    /// instance transfer (or nothing) instead.
46    pub fn is_supported(self) -> bool {
47        matches!(self, TransferStrategy::InstanceTransfer)
48    }
49}
50
51/// Domain adaptation state: the characteristics of the source and target
52/// domains and the resulting per-feature adaptation weights.
53#[derive(Debug, Clone)]
54pub struct DomainAdaptation<A: Float + Send + Sync> {
55    /// Source domain characteristics, as last registered.
56    source_characteristics: Vec<A>,
57    /// Target domain characteristics, as last reported.
58    target_characteristics: Vec<A>,
59    /// Per-feature adaptation weights: `target_i / source_i` where the source
60    /// coordinate is non-zero, so a source feature on a different scale is
61    /// rescaled rather than transferred verbatim.
62    adaptation_weights: Vec<A>,
63    /// Cosine similarity between the two characteristic vectors, or `None`
64    /// before both have been supplied.
65    domain_similarity: Option<A>,
66}
67
68impl<A: Float + Send + Sync> Default for DomainAdaptation<A> {
69    fn default() -> Self {
70        Self {
71            source_characteristics: Vec::new(),
72            target_characteristics: Vec::new(),
73            adaptation_weights: Vec::new(),
74            domain_similarity: None,
75        }
76    }
77}
78
79impl<A: Float + Send + Sync> DomainAdaptation<A> {
80    /// Records the source domain's characteristic vector.
81    pub fn set_source_characteristics(&mut self, characteristics: Vec<A>) {
82        self.source_characteristics = characteristics;
83        self.recompute();
84    }
85
86    /// Records the target domain's characteristic vector.
87    pub fn set_target_characteristics(&mut self, characteristics: Vec<A>) {
88        self.target_characteristics = characteristics;
89        self.recompute();
90    }
91
92    /// Cosine similarity between source and target domains, or `None` until
93    /// both characteristic vectors are available and non-degenerate.
94    pub fn domain_similarity(&self) -> Option<A> {
95        self.domain_similarity
96    }
97
98    /// Per-feature adaptation weights derived from the two characteristic
99    /// vectors.
100    pub fn adaptation_weights(&self) -> &[A] {
101        &self.adaptation_weights
102    }
103
104    fn recompute(&mut self) {
105        let shared = self
106            .source_characteristics
107            .len()
108            .min(self.target_characteristics.len());
109        if shared == 0 {
110            self.domain_similarity = None;
111            self.adaptation_weights.clear();
112            return;
113        }
114
115        let mut dot = A::zero();
116        let mut source_norm = A::zero();
117        let mut target_norm = A::zero();
118        self.adaptation_weights.clear();
119        for index in 0..shared {
120            let source = self.source_characteristics[index];
121            let target = self.target_characteristics[index];
122            dot = dot + source * target;
123            source_norm = source_norm + source * source;
124            target_norm = target_norm + target * target;
125            self.adaptation_weights.push(if source == A::zero() {
126                A::one()
127            } else {
128                target / source
129            });
130        }
131
132        self.domain_similarity = if source_norm > A::zero() && target_norm > A::zero() {
133            Some(dot / (source_norm.sqrt() * target_norm.sqrt()))
134        } else {
135            None
136        };
137    }
138}
139
140/// Measured transfer-learning outcomes.
141///
142/// Every field starts at its neutral value and only moves when a transfer has
143/// actually been evaluated, so a fresh instance reports "nothing measured yet"
144/// rather than a flattering constant.
145#[derive(Debug, Clone)]
146pub struct TransferMetrics<A: Float + Send + Sync> {
147    /// Transfers that improved the target's reward, as a fraction of those
148    /// evaluated. `None` until at least one transfer has been evaluated.
149    pub success_rate: Option<A>,
150    /// Mean reward improvement across evaluated transfers.
151    pub improvement: Option<A>,
152    /// Fraction of transferred experiences that were retained (not discarded
153    /// for low domain similarity).
154    pub efficiency: Option<A>,
155    /// Transfers that made the target worse.
156    pub negative_transfer_count: usize,
157    /// Transfers evaluated so far.
158    pub evaluated_transfers: usize,
159}
160
161impl<A: Float + Send + Sync> Default for TransferMetrics<A> {
162    fn default() -> Self {
163        Self {
164            success_rate: None,
165            improvement: None,
166            efficiency: None,
167            negative_transfer_count: 0,
168            evaluated_transfers: 0,
169        }
170    }
171}
172
173/// Minimum cosine similarity between domains before source experiences are
174/// replayed into the target at all. Below this, transfer is more likely to be
175/// negative than helpful, so nothing is transferred.
176pub const MIN_TRANSFER_SIMILARITY: f64 = 0.5;
177
178/// Transfer learning system: stores per-source-domain experiences and replays
179/// them into a target domain, weighted by measured domain similarity.
180#[derive(Debug, Clone)]
181pub struct TransferLearning<A: Float + Send + Sync> {
182    /// Source domain experiences, keyed by source task id.
183    source_experiences: HashMap<String, Vec<MetaExperience<A>>>,
184    /// Strategies this instance was asked to use.
185    transfer_strategies: Vec<TransferStrategy>,
186    /// Domain adaptation state.
187    domain_adaptation: DomainAdaptation<A>,
188    /// Measured transfer outcomes.
189    transfer_metrics: TransferMetrics<A>,
190    /// Cumulative reward improvement across evaluated transfers, used to derive
191    /// `TransferMetrics::improvement`.
192    improvement_total: A,
193    /// Evaluated transfers that improved the target.
194    successful_transfers: usize,
195}
196
197impl<A: Float + Send + Sync + Clone> Default for TransferLearning<A> {
198    fn default() -> Self {
199        Self::new()
200    }
201}
202
203impl<A: Float + Send + Sync + Clone> TransferLearning<A> {
204    /// Creates a transfer-learning system using instance transfer.
205    pub fn new() -> Self {
206        Self {
207            source_experiences: HashMap::new(),
208            transfer_strategies: vec![TransferStrategy::InstanceTransfer],
209            domain_adaptation: DomainAdaptation::default(),
210            transfer_metrics: TransferMetrics::default(),
211            improvement_total: A::zero(),
212            successful_transfers: 0,
213        }
214    }
215
216    /// Requests a specific set of transfer strategies.
217    ///
218    /// Returns an error naming any strategy this crate cannot carry out, rather
219    /// than accepting it and silently substituting instance transfer.
220    pub fn set_strategies(&mut self, strategies: Vec<TransferStrategy>) -> Result<(), String> {
221        if let Some(unsupported) = strategies.iter().find(|s| !s.is_supported()) {
222            return Err(format!(
223                "transfer strategy {unsupported:?} is not implemented: the meta-learner \
224                 stores experiences, not model parameters, feature extractors or \
225                 relational structure; only InstanceTransfer is supported"
226            ));
227        }
228        if strategies.is_empty() {
229            return Err("at least one transfer strategy is required".to_string());
230        }
231        self.transfer_strategies = strategies;
232        Ok(())
233    }
234
235    /// Strategies currently in force.
236    pub fn strategies(&self) -> &[TransferStrategy] {
237        &self.transfer_strategies
238    }
239
240    /// Registers a source domain's experiences and characteristic vector.
241    pub fn register_source(
242        &mut self,
243        source_id: String,
244        experiences: Vec<MetaExperience<A>>,
245        characteristics: Vec<A>,
246    ) {
247        self.domain_adaptation
248            .set_source_characteristics(characteristics);
249        self.source_experiences.insert(source_id, experiences);
250    }
251
252    /// Number of registered source domains.
253    pub fn source_domain_count(&self) -> usize {
254        self.source_experiences.len()
255    }
256
257    /// Measured transfer outcomes.
258    pub fn metrics(&self) -> &TransferMetrics<A> {
259        &self.transfer_metrics
260    }
261
262    /// Similarity between the registered source and the last reported target.
263    pub fn domain_similarity(&self) -> Option<A> {
264        self.domain_adaptation.domain_similarity()
265    }
266
267    /// Selects source experiences to replay into a target domain described by
268    /// `target_characteristics`.
269    ///
270    /// Returns an empty vector when there is no registered source, when the
271    /// domains are not similar enough (below [`MIN_TRANSFER_SIMILARITY`]), or
272    /// when the similarity cannot be computed — all of which are honest
273    /// "nothing to transfer" outcomes rather than errors.
274    pub fn select_transfer_batch(
275        &mut self,
276        target_characteristics: Vec<A>,
277        limit: usize,
278    ) -> Vec<MetaExperience<A>> {
279        self.domain_adaptation
280            .set_target_characteristics(target_characteristics);
281
282        let available: usize = self.source_experiences.values().map(Vec::len).sum();
283        if available == 0 || limit == 0 {
284            return Vec::new();
285        }
286
287        let Some(similarity) = self.domain_adaptation.domain_similarity() else {
288            return Vec::new();
289        };
290        let threshold = A::from(MIN_TRANSFER_SIMILARITY).unwrap_or_else(A::zero);
291        if similarity < threshold {
292            self.record_efficiency(0, available);
293            return Vec::new();
294        }
295
296        // Take the highest-priority source experiences first: those are the ones
297        // prioritized replay would have picked in their own domain.
298        let mut candidates: Vec<MetaExperience<A>> = self
299            .source_experiences
300            .values()
301            .flat_map(|batch| batch.iter().cloned())
302            .collect();
303        candidates.sort_by(|a, b| crate::utils::total_order(&b.priority, &a.priority));
304        candidates.truncate(limit);
305
306        // Scale each transferred experience's replay priority by the measured
307        // domain similarity, so a marginally similar domain contributes
308        // correspondingly less.
309        for experience in &mut candidates {
310            experience.priority = experience.priority * similarity;
311        }
312
313        self.record_efficiency(candidates.len(), available);
314        candidates
315    }
316
317    /// Records the outcome of a completed transfer: the target's reward before
318    /// and after replaying the transferred experiences.
319    pub fn record_transfer_outcome(&mut self, reward_before: A, reward_after: A) {
320        let improvement = reward_after - reward_before;
321        self.transfer_metrics.evaluated_transfers += 1;
322        self.improvement_total = self.improvement_total + improvement;
323
324        if improvement > A::zero() {
325            self.successful_transfers += 1;
326        } else if improvement < A::zero() {
327            self.transfer_metrics.negative_transfer_count += 1;
328        }
329
330        if let Some(evaluated) = A::from(self.transfer_metrics.evaluated_transfers) {
331            if evaluated > A::zero() {
332                self.transfer_metrics.improvement = Some(self.improvement_total / evaluated);
333                self.transfer_metrics.success_rate =
334                    A::from(self.successful_transfers).map(|successes| successes / evaluated);
335            }
336        }
337    }
338
339    fn record_efficiency(&mut self, retained: usize, available: usize) {
340        if available == 0 {
341            self.transfer_metrics.efficiency = None;
342            return;
343        }
344        let (Some(retained), Some(available)) = (A::from(retained), A::from(available)) else {
345            return;
346        };
347        self.transfer_metrics.efficiency = Some(retained / available);
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    use crate::streaming::adaptive_streaming::meta_learning::{
355        EpisodeContext, EpisodeOutcome, MetaAction, MetaExperience, MetaState,
356    };
357    use crate::streaming::adaptive_streaming::optimizer::AdaptationType;
358    use std::time::{Duration, Instant};
359
360    fn experience(priority: f64, reward: f64) -> MetaExperience<f64> {
361        MetaExperience {
362            id: 1,
363            state: MetaState {
364                performance_metrics: vec![reward],
365                resource_state: vec![1.0],
366                drift_indicators: vec![0.0],
367                adaptation_history: 0,
368                timestamp: Instant::now(),
369            },
370            action: MetaAction {
371                adaptation_magnitudes: vec![0.1],
372                adaptation_types: vec![AdaptationType::LearningRate],
373                learning_rate_change: 0.1,
374                buffer_size_change: 0.0,
375                timestamp: Instant::now(),
376            },
377            reward,
378            next_state: None,
379            timestamp: Instant::now(),
380            episode_context: EpisodeContext {
381                episode_id: 0,
382                start_time: Instant::now(),
383                duration: Duration::ZERO,
384                initial_performance: 0.0,
385                final_performance: reward,
386                adaptation_count: 1,
387                outcome: EpisodeOutcome::Neutral,
388            },
389            priority,
390            replay_count: 0,
391        }
392    }
393
394    /// A fresh instance must report "nothing measured" — not the old fabricated
395    /// `success_rate: 0.5` / `improvement: 0.1` / `efficiency: 0.7`.
396    #[test]
397    fn fresh_metrics_are_absent_not_fabricated() {
398        let transfer = TransferLearning::<f64>::new();
399        let metrics = transfer.metrics();
400        assert!(metrics.success_rate.is_none());
401        assert!(metrics.improvement.is_none());
402        assert!(metrics.efficiency.is_none());
403        assert_eq!(metrics.evaluated_transfers, 0);
404        assert!(
405            transfer.domain_similarity().is_none(),
406            "no domain has been described yet, so there is no similarity to report"
407        );
408    }
409
410    /// Similar domains transfer; the transferred priorities are scaled by the
411    /// measured similarity rather than copied verbatim.
412    #[test]
413    fn similar_domains_transfer_with_similarity_scaled_priorities() {
414        let mut transfer = TransferLearning::<f64>::new();
415        transfer.register_source(
416            "source".to_string(),
417            vec![experience(1.0, 1.0), experience(0.2, 0.0)],
418            vec![1.0, 1.0],
419        );
420
421        let batch = transfer.select_transfer_batch(vec![1.0, 1.0], 2);
422        assert_eq!(batch.len(), 2, "identical domains must transfer everything");
423        let similarity = transfer.domain_similarity().expect("similarity");
424        assert!((similarity - 1.0).abs() < 1e-9, "similarity = {similarity}");
425        // Highest source priority first, scaled by similarity (1.0 here).
426        assert!((batch[0].priority - 1.0).abs() < 1e-9);
427        assert_eq!(transfer.metrics().efficiency, Some(1.0));
428    }
429
430    /// Dissimilar domains must transfer nothing rather than risk negative
431    /// transfer, and must say so through `efficiency`.
432    #[test]
433    fn dissimilar_domains_transfer_nothing() {
434        let mut transfer = TransferLearning::<f64>::new();
435        transfer.register_source(
436            "source".to_string(),
437            vec![experience(1.0, 1.0)],
438            vec![1.0, 0.0],
439        );
440
441        // Orthogonal characteristics: cosine similarity 0.
442        let batch = transfer.select_transfer_batch(vec![0.0, 1.0], 4);
443        assert!(batch.is_empty(), "orthogonal domains must not transfer");
444        assert_eq!(transfer.metrics().efficiency, Some(0.0));
445    }
446
447    /// Outcomes are measured from the rewards actually observed.
448    #[test]
449    fn transfer_outcomes_are_measured() {
450        let mut transfer = TransferLearning::<f64>::new();
451        transfer.record_transfer_outcome(1.0, 2.0); // +1.0, success
452        transfer.record_transfer_outcome(1.0, 0.0); // -1.0, negative transfer
453
454        let metrics = transfer.metrics();
455        assert_eq!(metrics.evaluated_transfers, 2);
456        assert_eq!(metrics.negative_transfer_count, 1);
457        assert_eq!(metrics.success_rate, Some(0.5));
458        assert_eq!(metrics.improvement, Some(0.0));
459    }
460
461    /// Unsupported strategies are refused by name instead of being accepted and
462    /// silently downgraded to instance transfer.
463    #[test]
464    fn unsupported_strategies_are_refused() {
465        let mut transfer = TransferLearning::<f64>::new();
466        let err = transfer
467            .set_strategies(vec![TransferStrategy::ParameterTransfer])
468            .expect_err("ParameterTransfer must be refused");
469        assert!(err.contains("ParameterTransfer"), "{err}");
470        transfer
471            .set_strategies(vec![TransferStrategy::InstanceTransfer])
472            .expect("InstanceTransfer is supported");
473        assert_eq!(transfer.strategies(), [TransferStrategy::InstanceTransfer]);
474    }
475}