Coverage for packages/dqm-ml-core/src/dqm_ml_core/utils/processor_runner.py: 96%

90 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-07-21 08:27 +0000

1"""Processor runner utility for executing metrics on DataFrames. 

2 

3This module contains the ProcessorRunner class that provides a high-level 

4API for running feature and metric processors directly on Pandas DataFrames. 

5""" 

6 

7from __future__ import annotations 

8 

9import logging 

10from typing import Any 

11 

12import numpy as np 

13from pandas import DataFrame 

14import pyarrow as pa 

15 

16from dqm_ml_core.api.gap_processor import GapProcessor 

17from dqm_ml_core.api.metrics_processor import MetricsProcessor 

18from dqm_ml_core.api.processor import Processor 

19 

20logger = logging.getLogger(__name__) 

21 

22 

23class ProcessorRunner: 

24 """ 

25 Orchestrator for executing metric processors on in-memory Pandas DataFrames. 

26 

27 This class provides a high-level API for users who want to compute metrics 

28 directly on DataFrames without using the full YAML-driven pipeline. 

29 """ 

30 

31 def __init__(self, config: dict[str, Any] | None = None) -> None: 

32 """ 

33 Initialize the runner. 

34 

35 Args: 

36 config: Optional configuration for metric default behaviors. 

37 """ 

38 self.config = config or {} 

39 

40 @staticmethod 

41 def _df_to_record_batch(df: DataFrame) -> pa.RecordBatch: 

42 """Convert a pandas DataFrame to a pyarrow RecordBatch. 

43 

44 Handles conversion of object columns containing uniform-size numpy 

45 arrays (e.g. embeddings) to FixedSizeListArray, which is required 

46 by processors like DomainGapProcessor. 

47 """ 

48 arrays: list[pa.Array] = [] 

49 for col_name in df.columns: 

50 series = df[col_name] 

51 if series.dtype == object and len(series) > 0: 

52 first = series.iloc[0] 

53 if isinstance(first, np.ndarray): 53 ↛ 60line 53 didn't jump to line 60 because the condition on line 53 was always true

54 dim = first.shape[0] 

55 if all(isinstance(v, np.ndarray) and v.shape == (dim,) for v in series): 55 ↛ 60line 55 didn't jump to line 60 because the condition on line 55 was always true

56 flat = np.vstack(series.values).astype(np.float64) 

57 arr = pa.FixedSizeListArray.from_arrays(pa.array(flat.flatten().tolist()), dim) 

58 arrays.append(arr) 

59 continue 

60 arrays.append(pa.Array.from_pandas(series)) 

61 table = pa.table(dict(zip(df.columns, arrays, strict=True))) 

62 return table.to_batches()[0] 

63 

64 def _compute_batch_level(self, df: DataFrame, processors: list[Processor]) -> tuple[dict[str, Any], dict[str, Any]]: 

65 """Compute features and batch-level metrics from non-Gap processors. 

66 

67 Returns: 

68 Tuple of (batch_features, batch_metrics) merged dicts. 

69 """ 

70 from dqm_ml_core.api.features_processor import FeaturesProcessor 

71 

72 batch = self._df_to_record_batch(df) 

73 batch_features: dict[str, Any] = {} 

74 batch_metrics: dict[str, Any] = {} 

75 

76 for metric in processors: 

77 logger.debug(f"Processing metric {metric.__class__.__name__}") 

78 if isinstance(metric, FeaturesProcessor): 

79 batch_features |= metric.compute_features(batch, prev_features=batch_features) 

80 elif isinstance(metric, MetricsProcessor): 80 ↛ 85line 80 didn't jump to line 85 because the condition on line 80 was always true

81 # select_columns returns intermediate columns for metric computation, 

82 # not final features - use only for compute_batch_metric 

83 intermediate = metric.select_columns(batch, prev_features=batch_features) 

84 

85 if isinstance(metric, MetricsProcessor): 

86 batch_metrics |= metric.compute_batch_metric(intermediate) 

87 

88 return batch_features, batch_metrics 

89 

90 def _compute_dataset_level(self, batch_metrics: dict[str, Any], processors: list[Processor]) -> dict[str, Any]: 

91 """Compute dataset-level metrics from MetricsProcessor instances.""" 

92 dataset_metrics: dict[str, Any] = {} 

93 for metric in processors: 

94 if not isinstance(metric, MetricsProcessor): 

95 continue 

96 logger.debug(f"Computing final score for {metric.__class__.__name__}") 

97 dataset_metrics |= metric.compute(batch_metrics=batch_metrics) 

98 return dataset_metrics 

99 

100 def run(self, df: DataFrame, processors: list[Processor]) -> dict[str, Any]: 

101 """ 

102 Execute the provided processors on a DataFrame. 

103 

104 Args: 

105 df: The input Pandas DataFrame. 

106 processors: List of initialized Processor instances. 

107 

108 Returns: 

109 A dictionary containing the aggregated dataset-level metrics 

110 and/or per-sample features. Features from FeaturesProcessor 

111 instances are included alongside metrics from MetricsProcessor instances. 

112 """ 

113 if df.empty or not processors: 

114 logger.warning("Empty DataFrame or no processors provided to ProcessorRunner") 

115 return {} 

116 

117 active = [p for p in processors if not isinstance(p, GapProcessor)] 

118 batch_features, batch_metrics = self._compute_batch_level(df, active) 

119 dataset_metrics = self._compute_dataset_level(batch_metrics, active) 

120 

121 # Merge features (from FeaturesProcessor) and metrics (from MetricsProcessor) 

122 result = {} 

123 result.update(batch_features) # Per-sample features 

124 result.update(dataset_metrics) # Aggregated metrics 

125 return result 

126 

127 def _compute_features(self, df: DataFrame, features: list[Processor]) -> DataFrame: 

128 """Run FeaturesProcessors on a DataFrame and return a new DataFrame with computed columns. 

129 

130 Args: 

131 df: The input DataFrame. 

132 features: List of FeaturesProcessor instances. 

133 

134 Returns: 

135 A new DataFrame with the original columns plus any new feature columns. 

136 """ 

137 result = self.run(df, features) 

138 if not result: 138 ↛ 139line 138 didn't jump to line 139 because the condition on line 138 was never true

139 return df 

140 out = df.copy() 

141 for col, values in result.items(): 

142 out[col] = values 

143 return out 

144 

145 def run_gap( 

146 self, 

147 source_df: DataFrame, 

148 target_df: DataFrame, 

149 processor: GapProcessor, 

150 features: list[Processor] | None = None, 

151 source_selection_name: str = "source", 

152 target_selection_name: str = "target", 

153 ) -> dict[str, Any]: 

154 """ 

155 Execute a GapProcessor on two DataFrames and compute the domain gap. 

156 

157 This method handles the two-dataset execution pattern required by GapProcessor: 

158 1. Optionally compute features (e.g. embeddings) on both DataFrames 

159 2. Process source DataFrame to compute source statistics 

160 3. Process target DataFrame to compute target statistics 

161 4. Compute the domain gap delta between source and target 

162 

163 Args: 

164 source_df: The source dataset DataFrame. 

165 target_df: The target dataset DataFrame. 

166 processor: An initialized GapProcessor instance. 

167 features: Optional list of FeaturesProcessor instances to run on both 

168 DataFrames before computing the gap. For example, pass an 

169 ImageEmbeddingProcessor to compute embeddings from raw images. 

170 source_selection_name: Name for the source selection (default: "source"). 

171 target_selection_name: Name for the target selection (default: "target"). 

172 

173 Returns: 

174 A dictionary containing: 

175 - {metric_name}: The domain gap metric value 

176 - "selection_source": Name of the source selection 

177 - "selection_target": Name of the target selection 

178 """ 

179 if source_df.empty or target_df.empty: 

180 logger.warning("Empty DataFrame provided to run_gap") 

181 return {} 

182 

183 # Compute features if provided (e.g. embeddings from raw images) 

184 if features: 

185 source_df = self._compute_features(source_df, features) 

186 target_df = self._compute_features(target_df, features) 

187 

188 # Process source dataset 

189 source_batch = self._df_to_record_batch(source_df) 

190 source_features = processor.select_features(source_batch, prev_features={}) 

191 source_batch_metrics = processor.compute_batch_metric(source_features) 

192 source_stats = processor.compute(source_batch_metrics) 

193 

194 # Process target dataset 

195 target_batch = self._df_to_record_batch(target_df) 

196 target_features = processor.select_features(target_batch, prev_features={}) 

197 target_batch_metrics = processor.compute_batch_metric(target_features) 

198 target_stats = processor.compute(target_batch_metrics) 

199 

200 # Compute domain gap delta 

201 result = processor.compute_delta(source_stats, target_stats) 

202 

203 # Add selection metadata 

204 result["selection_source"] = pa.array([source_selection_name]) 

205 result["selection_target"] = pa.array([target_selection_name]) 

206 

207 return result