|
| 1 | +"""Markov-based greedy evaluation subset selection.""" |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +import math |
| 6 | +from collections import Counter |
| 7 | + |
| 8 | +import numpy as np |
| 9 | +from scipy import sparse |
| 10 | + |
| 11 | + |
| 12 | +def _min_max_normalize(values: list[float]) -> list[float]: |
| 13 | + if not values: |
| 14 | + return [] |
| 15 | + low, high = min(values), max(values) |
| 16 | + if math.isclose(low, high): |
| 17 | + return [0.0] * len(values) |
| 18 | + return [(v - low) / (high - low) for v in values] |
| 19 | + |
| 20 | + |
| 21 | +def _build_markov_model( |
| 22 | + op_seqs: list[tuple[str, ...]], |
| 23 | +) -> tuple[dict[str, int], sparse.csr_matrix, np.ndarray]: |
| 24 | + operators = sorted({op for seq in op_seqs for op in seq}) |
| 25 | + op_to_id = {op: idx for idx, op in enumerate(operators)} |
| 26 | + |
| 27 | + transitions: Counter[tuple[str, str]] = Counter() |
| 28 | + for seq in op_seqs: |
| 29 | + for src, dst in zip(seq[:-1], seq[1:]): |
| 30 | + transitions[(src, dst)] += 1 |
| 31 | + |
| 32 | + rows, cols, data = [], [], [] |
| 33 | + for (src, dst), cnt in transitions.items(): |
| 34 | + rows.append(op_to_id[src]) |
| 35 | + cols.append(op_to_id[dst]) |
| 36 | + data.append(cnt) |
| 37 | + |
| 38 | + size = len(operators) |
| 39 | + count_matrix = sparse.coo_matrix((data, (rows, cols)), shape=(size, size)).tocsr() |
| 40 | + row_sums = np.asarray(count_matrix.sum(axis=1)).ravel() |
| 41 | + return op_to_id, count_matrix, row_sums |
| 42 | + |
| 43 | + |
| 44 | +def _transition_prob( |
| 45 | + op_to_id: dict[str, int], |
| 46 | + count_matrix: sparse.csr_matrix, |
| 47 | + row_sums: np.ndarray, |
| 48 | + src: str, |
| 49 | + dst: str, |
| 50 | + alpha: float, |
| 51 | +) -> float: |
| 52 | + """Laplace-smoothed P(dst | src).""" |
| 53 | + vocab_size = count_matrix.shape[1] |
| 54 | + src_id, dst_id = op_to_id[src], op_to_id[dst] |
| 55 | + count = float(count_matrix[src_id, dst_id]) |
| 56 | + denom = float(row_sums[src_id]) + alpha * vocab_size |
| 57 | + return (count + alpha) / denom if denom else 1.0 / vocab_size |
| 58 | + |
| 59 | + |
| 60 | +def _compute_metrics( |
| 61 | + op_seqs: list[tuple[str, ...]], |
| 62 | + op_to_id: dict[str, int], |
| 63 | + count_matrix: sparse.csr_matrix, |
| 64 | + row_sums: np.ndarray, |
| 65 | + alpha: float, |
| 66 | +) -> list[dict]: |
| 67 | + result = [] |
| 68 | + for seq in op_seqs: |
| 69 | + edges = list(zip(seq[:-1], seq[1:])) |
| 70 | + if not edges: |
| 71 | + result.append( |
| 72 | + {"rarity_score": 0.0, "unique_edges": frozenset(), "edge_count": 0} |
| 73 | + ) |
| 74 | + continue |
| 75 | + neg_log_probs = [ |
| 76 | + -math.log( |
| 77 | + _transition_prob(op_to_id, count_matrix, row_sums, src, dst, alpha) |
| 78 | + ) |
| 79 | + for src, dst in edges |
| 80 | + ] |
| 81 | + result.append( |
| 82 | + { |
| 83 | + "rarity_score": sum(neg_log_probs) / len(neg_log_probs), |
| 84 | + "unique_edges": frozenset(edges), |
| 85 | + "edge_count": len(edges), |
| 86 | + } |
| 87 | + ) |
| 88 | + return result |
| 89 | + |
| 90 | + |
| 91 | +def _greedy_select(metrics: list[dict], k: int, rarity_weight: float) -> list[int]: |
| 92 | + """Greedily maximise edge coverage, weighted by rarity score.""" |
| 93 | + target = min(k, len(metrics)) |
| 94 | + rarity_norm = _min_max_normalize([m["rarity_score"] for m in metrics]) |
| 95 | + |
| 96 | + selected: list[int] = [] |
| 97 | + selected_set: set[int] = set() |
| 98 | + covered_edges: set[tuple[str, str]] = set() |
| 99 | + |
| 100 | + while len(selected) < target: |
| 101 | + best_idx, best_key = None, None |
| 102 | + for idx, metric in enumerate(metrics): |
| 103 | + if idx in selected_set: |
| 104 | + continue |
| 105 | + new_edge_count = len(metric["unique_edges"] - covered_edges) |
| 106 | + bonus = rarity_weight * rarity_norm[idx] |
| 107 | + gain = float(new_edge_count) * (1.0 + bonus) |
| 108 | + key = (gain, new_edge_count, bonus, metric["edge_count"]) |
| 109 | + if best_key is None or key > best_key: |
| 110 | + best_idx, best_key = idx, key |
| 111 | + if best_idx is None: |
| 112 | + break |
| 113 | + selected.append(best_idx) |
| 114 | + selected_set.add(best_idx) |
| 115 | + covered_edges.update(metrics[best_idx]["unique_edges"]) |
| 116 | + |
| 117 | + return selected |
| 118 | + |
| 119 | + |
| 120 | +def select_evaluation_subset( |
| 121 | + op_seqs: list[tuple[str, ...]], |
| 122 | + k: int, |
| 123 | + *, |
| 124 | + smoothing_alpha: float = 1e-3, |
| 125 | + rarity_weight: float = 1, |
| 126 | +) -> list[tuple[str, ...]]: |
| 127 | + """Select k sequences from op_seqs using Markov-based greedy coverage. |
| 128 | +
|
| 129 | + Builds a Markov model over all sequences, scores each by rarity |
| 130 | + (mean -log transition prob), then greedily picks sequences that |
| 131 | + maximise new edge coverage weighted by rarity. |
| 132 | +
|
| 133 | + Returns selected sequences in greedy order (most valuable first). |
| 134 | + """ |
| 135 | + if k <= 0: |
| 136 | + return [] |
| 137 | + seqs = [tuple(s) for s in op_seqs] |
| 138 | + if k >= len(seqs): |
| 139 | + return list(seqs) |
| 140 | + |
| 141 | + op_to_id, count_matrix, row_sums = _build_markov_model(seqs) |
| 142 | + metrics = _compute_metrics(seqs, op_to_id, count_matrix, row_sums, smoothing_alpha) |
| 143 | + return [seqs[i] for i in _greedy_select(metrics, k, rarity_weight)] |
0 commit comments