{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.11.13","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"none","dataSources":[{"sourceId":102335,"databundleVersionId":12518947,"sourceType":"competition"}],"dockerImageVersionId":31089,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":false}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"#################################\n# PostProcessor\n#################################\nimport numpy as np\n\ndef _softmax(x: np.ndarray, axis: int = -1) -> np.ndarray:\n    x_max = np.max(x, axis=axis, keepdims=True)\n    e = np.exp(x - x_max)\n    s = e / np.sum(e, axis=axis, keepdims=True)\n    return s\n\n\nclass PostProcessor:\n    \"\"\"Post-process logits with priors and simple constraints.\"\"\"\n\n    def __init__(\n        self,\n        n_classes: int = 18,\n        warmup_k: int = 25,\n        lambda_prior: float = 0.6,\n        high_conf_threshold: float = 0.9,\n        group_map: dict | None = None,\n        group_targets: dict | None = None,\n        group_tolerances: dict | None = None,\n        temperature_per_model: list[float] | None = None,\n        weights_per_model: list[float] | None = None,\n        apply_global_adjust: bool = True,\n        seed: int | None = 42,\n        ema_on: bool = True,\n        ema_beta: float = 0.01,\n        gamma_deficit: float = 0.01,\n        use_ema: bool | None = None,\n        use_negative_evidence: bool = False,\n        kappa_neg: float = 0.0,\n        kappa_neg_per_group: dict | None = None,\n        penalty_strength: float = 2.0,\n        penalty_classes: list[int] | None = None,\n\n        aggregated_logits: bool = False,\n        aggregated_temperature: float | None = 1.0,\n    ) -> None:\n        self.n_classes = n_classes\n        self.warmup_k = warmup_k\n        self.lambda_prior = lambda_prior\n        self.high_conf_threshold = high_conf_threshold\n        # groups: {'freq': [ids], 'mid': [ids], 'rare': [ids]}\n        self.group_map = group_map or {'freq': list(range(9)), 'mid': [9, 10, 11], 'rare': list(range(12, 18))}\n        # expected per 100\n        self.group_targets = group_targets or {'freq': 8, 'mid': 6, 'rare': 2}\n        # tolerances per 100\n        self.group_tolerances = group_tolerances or {'freq': 2, 'mid': 2, 'rare': 1}\n        self.temperature_per_model = temperature_per_model\n        self.weights_per_model = weights_per_model\n        self.apply_global_adjust = apply_global_adjust\n        self.rng = np.random.default_rng(seed) if seed is not None else np.random.default_rng()\n        # EMA params\n        self.ema_on = ema_on if use_ema is None else bool(use_ema)\n        self.ema_beta = float(ema_beta)\n        # Online prior deficit boost strength\n        self.gamma_deficit = float(gamma_deficit)\n        # Negative evidence params\n        self.use_negative_evidence = bool(use_negative_evidence)\n        self.kappa_neg = float(kappa_neg)\n        self.kappa_neg_per_group = kappa_neg_per_group\n        # Binary penalty params\n        self.penalty_strength = float(penalty_strength)\n        self.penalty_classes = penalty_classes or [8, 9, 10, 11, 12, 13, 14, 15, 16, 17]\n        # Aggregation mode\n        self.aggregated_logits = bool(aggregated_logits)\n        self.aggregated_temperature = 1.0 if aggregated_temperature is None else float(aggregated_temperature)\n\n    def _ensure_shapes(self, logits_list: list[np.ndarray]) -> None:\n        if not logits_list:\n            raise ValueError('logits_list is empty')\n        T = None\n        for arr in logits_list:\n            if arr.ndim != 2 or arr.shape[1] != self.n_classes:\n                raise ValueError('Each logits array must have shape (T, n_classes)')\n            if T is None:\n                T = arr.shape[0]\n            elif T != arr.shape[0]:\n                raise ValueError('All logits arrays must have the same T along axis 0')\n\n    def _apply_temperature(self, logits: np.ndarray, T: float) -> np.ndarray:\n        if T <= 0:\n            raise ValueError('Temperature must be positive')\n        return logits / T\n\n    def _kappa_per_class(self) -> np.ndarray:\n        \"\"\"Resolve per-class κ from scalar or per-group dict.\"\"\"\n        kappas = np.full(self.n_classes, float(self.kappa_neg), dtype=float)\n        if isinstance(self.kappa_neg_per_group, dict):\n            for g, ids in (self.group_map or {}).items():\n                if not ids:\n                    continue\n                val = self.kappa_neg_per_group.get(g)\n                if val is None:\n                    continue\n                kappas[np.array(ids, dtype=int)] = float(val)\n        return kappas\n\n    def _apply_negative_penalty(self, logits: np.ndarray, negative_evidence: np.ndarray | None) -> np.ndarray:\n        if (not self.use_negative_evidence) or negative_evidence is None:\n            return logits\n        if negative_evidence.ndim != 2 or negative_evidence.shape[1] != self.n_classes:\n            raise ValueError('negative_evidence must have shape (T, n_classes)')\n        # magnitude of anti-support: accept both negative-coded and positive-coded inputs\n        neg_mag = np.where(negative_evidence < 0.0, -negative_evidence, negative_evidence).astype(float)\n        kappa_cls = self._kappa_per_class().reshape(1, -1)\n        return logits - neg_mag * kappa_cls\n\n    def _apply_binary_penalty(self, logits: np.ndarray, binary_predictions: np.ndarray | None) -> np.ndarray:\n        \"\"\"Apply penalty to specified classes based on binary_prediction\"\"\"\n        if binary_predictions is None:\n            return logits\n        \n        # binary_prediction shows probability that this is a gesture (classes 0-7)\n        # If binary_prediction is high, then penalty_classes should get penalty\n        penalty = np.zeros_like(logits)\n        for cls in self.penalty_classes:\n            if cls < self.n_classes:  # safety check\n                penalty[:, cls] = binary_predictions.flatten() * self.penalty_strength\n        \n        return logits - penalty\n\n    def _build_bounds(self, T: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]:\n        # targets per class based on group assignment\n        targets = np.zeros(self.n_classes, dtype=float)\n        uppers = np.full(self.n_classes, np.inf, dtype=float)\n        lowers = np.zeros(self.n_classes, dtype=float)\n        for g, ids in self.group_map.items():\n            if not ids:\n                continue\n            per_class_target = self.group_targets[g]\n            tol = self.group_tolerances[g]\n            for cid in ids:\n                # scale is currntly fixed 100 - assume 100 default\n                base_T = 100 #max(100, int(T))\n                scale = base_T / 100.0\n                tgt = per_class_target * scale\n                lower = max(0.0, (per_class_target - tol) * scale)\n                upper = max(lower, (per_class_target + tol) * scale)\n                targets[cid] = tgt\n                lowers[cid] = lower\n                uppers[cid] = upper\n        return targets, lowers, uppers\n\n    def _online_adjust(self, probs: np.ndarray, targets: np.ndarray, lowers: np.ndarray, uppers: np.ndarray) -> np.ndarray:\n        T, C = probs.shape\n        assigned = np.full(T, -1, dtype=int)\n        counts = np.zeros(C, dtype=int)\n        eps = 1e-6\n        # EMA smoothing to stabilize online decisions\n        ema = None\n        use_ema = bool(self.ema_on)\n        beta = float(self.ema_beta)\n        for t in range(T):\n            if use_ema:\n                p_raw = probs[t]\n                if ema is None:\n                    ema = p_raw.copy()\n                else:\n                    ema = beta * ema + (1.0 - beta) * p_raw\n                p = ema\n            else:\n                p = probs[t]\n\n            top1 = int(np.argmax(p))\n            # quick accept on warmup or very high confidence\n            if t < self.warmup_k or p[top1] >= self.high_conf_threshold:\n                choice = top1\n            else:\n                # Remaining room to upper bounds and deficits to lower bounds\n                residuals = np.maximum(uppers - counts, 0.0)\n                deficits = np.maximum(lowers - counts, 0.0)\n\n                # Soft prior: prefer classes with room to upper and encourage filling below-lower ones\n                # gamma controls strength of deficit encouragement\n                gamma = float(self.gamma_deficit)\n                prior_raw = residuals + gamma * deficits\n                prior = prior_raw / (targets + eps)\n                prior = np.clip(prior, eps, None)\n\n                # Adaptive lambda by confidence margin: stronger prior when margin is low\n                if C >= 2:\n                    # fast top-2 margin from smoothed probs\n                    top2_val = np.partition(p, -2)[-2]\n                    margin = float(p[top1] - top2_val)\n                else:\n                    margin = 1.0\n                # scale in [~1, ~2]; when margin small -> larger weight on prior\n                lambda_eff = self.lambda_prior * (1.0 + (1.0 - max(0.0, min(1.0, margin))))\n\n                score = np.log(p + eps) + lambda_eff * np.log(prior)\n                choice = int(np.argmax(score))\n\n            assigned[t] = choice\n            counts[choice] += 1\n        return assigned\n\n    def process_subject(self, logits_list: list[np.ndarray] | np.ndarray, negative_evidence: np.ndarray | None = None, binary_predictions: np.ndarray | None = None) -> dict:\n        \"\"\"Post-process subject predictions.\n        - Default: pass a list of model logits [arr(T×C), ...] and ensembling happens inside.\n        - If self.aggregated_logits is True: pass a single logits array (T×C); averaging is done outside.\n        Supports optional negative_evidence (T×C) and binary_predictions (T×1).\n        \"\"\"\n        # Resolve logits depending on aggregation mode\n        if getattr(self, 'aggregated_logits', False):\n            if isinstance(logits_list, np.ndarray):\n                logits = logits_list\n            elif isinstance(logits_list, (list, tuple)) and len(logits_list) == 1 and isinstance(logits_list[0], np.ndarray):\n                logits = logits_list[0]\n            else:\n                raise ValueError('aggregated_logits=True expects a single (T, n_classes) logits array')\n            if logits.ndim != 2 or logits.shape[1] != self.n_classes:\n                raise ValueError('Aggregated logits must have shape (T, n_classes)')\n            # Apply single global temperature to aggregated logits\n            Tglob = float(getattr(self, 'aggregated_temperature', 1.0))\n            if Tglob <= 0.0:\n                raise ValueError('aggregated_temperature must be positive')\n            if Tglob != 1.0:\n                logits = logits / Tglob\n        else:\n            self._ensure_shapes(logits_list)  # type: ignore[arg-type]\n            logits = self._ensemble_logits(logits_list)  # type: ignore[arg-type]\n\n        logits = self._apply_negative_penalty(logits, negative_evidence)\n        logits = self._apply_binary_penalty(logits, binary_predictions)\n        probs18 = _softmax(logits, axis=1)\n        T = probs18.shape[0]\n\n        targets, lowers, uppers = self._build_bounds(T)\n        online_labels = self._online_adjust(probs18, targets, lowers, uppers)\n\n        return {\n                'online_labels': online_labels,\n                'probs': probs18,\n                'bounds': {'targets': targets, 'lowers': lowers, 'uppers': uppers},\n            }\n\n    def _greedy_adjust(self, probs: np.ndarray, initial: np.ndarray, targets: np.ndarray, lowers: np.ndarray, uppers: np.ndarray) -> np.ndarray:\n        \"\"\"Single-pass per-item refinement: reassign each item using counts of others.\n        No EMA; uses high-confidence shortcut; same prior scoring as _online_adjust.\n        \"\"\"\n        T, C = probs.shape\n        labels = initial.copy()\n        eps = 1e-6\n        for i in range(T):\n            # counts of others\n            counts = np.bincount(labels, minlength=C).astype(float)\n            cur = int(labels[i]) if labels[i] >= 0 else -1\n            if cur >= 0:\n                counts[cur] -= 1.0\n            p = probs[i]\n            top1 = int(np.argmax(p))\n            if p[top1] >= self.high_conf_threshold:\n                choice = top1\n            else:\n                residuals = np.maximum(uppers - counts, 0.0)\n                deficits = np.maximum(lowers - counts, 0.0)\n                gamma = float(self.gamma_deficit)\n                prior_raw = residuals + gamma * deficits\n                prior = prior_raw / (targets + eps)\n                prior = np.clip(prior, eps, None)\n                if C >= 2:\n                    top2_val = np.partition(p, -2)[-2]\n                    margin = float(p[top1] - top2_val)\n                else:\n                    margin = 1.0\n                lambda_eff = self.lambda_prior * (1.0 + (1.0 - max(0.0, min(1.0, margin))))\n                score = np.log(p + eps) + lambda_eff * np.log(prior)\n                choice = int(np.argmax(score))\n            labels[i] = choice\n        return labels\n\n    def process_stream_last_only(self, logits_list: list[np.ndarray] | np.ndarray, negative_evidence: np.ndarray | None = None, binary_predictions: np.ndarray | None = None) -> dict:\n        \"\"\"Stream-like: at each t recompute best labels on prefix [0..t] and output only the last.\n        Returns dict with 'last_labels_per_t', 'probs', and 'bounds_last'.\n        Supports optional negative_evidence (T×C) and binary_predictions (T×1).\n        \"\"\"\n        # Resolve logits depending on aggregation mode (same as process_subject)\n        if getattr(self, 'aggregated_logits', False):\n            if isinstance(logits_list, np.ndarray):\n                logits = logits_list\n            elif isinstance(logits_list, (list, tuple)) and len(logits_list) == 1 and isinstance(logits_list[0], np.ndarray):\n                logits = logits_list[0]\n            else:\n                raise ValueError('aggregated_logits=True expects a single (T, n_classes) logits array')\n            if logits.ndim != 2 or logits.shape[1] != self.n_classes:\n                raise ValueError('Aggregated logits must have shape (T, n_classes)')\n            Tglob = float(getattr(self, 'aggregated_temperature', 1.0))\n            if Tglob <= 0.0:\n                raise ValueError('aggregated_temperature must be positive')\n            if Tglob != 1.0:\n                logits = logits / Tglob\n        else:\n            self._ensure_shapes(logits_list)  # type: ignore[arg-type]\n            logits = self._ensemble_logits(logits_list)  # type: ignore[arg-type]\n\n        logits = self._apply_negative_penalty(logits, negative_evidence)\n        logits = self._apply_binary_penalty(logits, binary_predictions)\n        probs18 = _softmax(logits, axis=1)\n        T = probs18.shape[0]\n        last_labels = np.empty(T, dtype=int)\n\n        last_bounds: dict | None = None\n        for t in range(T):\n            prefix = probs18[:t+1]\n            targets, lowers, uppers = self._build_bounds(t + 1)\n            initial = self._online_adjust(prefix, targets, lowers, uppers)\n            if self.apply_global_adjust:\n                labels = self._greedy_adjust(prefix, initial, targets, lowers, uppers)\n            else:\n                labels = initial\n            last_labels[t] = int(labels[-1])\n            if t == T - 1:\n                last_bounds = {'targets': targets, 'lowers': lowers, 'uppers': uppers}\n\n        return {\n            'last_labels_per_t': last_labels,\n            'probs': probs18,\n            'bounds_last': last_bounds,\n        }","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true,"execution":{"iopub.status.busy":"2025-09-03T08:13:28.954103Z","iopub.execute_input":"2025-09-03T08:13:28.954387Z","iopub.status.idle":"2025-09-03T08:13:28.996254Z","shell.execute_reply.started":"2025-09-03T08:13:28.954366Z","shell.execute_reply":"2025-09-03T08:13:28.995225Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"#################################\n# Example\n#################################\n\n# I've changed normal labels encodings - please put yours\nper_class_share = {\n    0: 0.078273, 1: 0.078518, 2: 0.078273, 3: 0.078150,\n    4: 0.078518, 5: 0.078518, 6: 0.078518, 7: 0.078518, 8: 0.078518,\n    9: 0.019752, 10: 0.019752, 11: 0.019752, 12: 0.058520, 13: 0.058643,\n    14: 0.019752, 15: 0.019752, 16: 0.058520, 17: 0.019752,\n}\norder = sorted(per_class_share.keys(), key=lambda k: per_class_share[k], reverse=True)\nfreq_ids = order[:9]\nmid_ids = order[9:12]\nrare_ids = order[12:]\ngroup_map = {'freq': freq_ids, 'mid': mid_ids, 'rare': rare_ids}\ngroup_targets = {'freq': 8, 'mid': 6, 'rare': 2}\ngroup_tolerances = {'freq': 3, 'mid': 3, 'rare': 1}\n\npp_cfg = {\n    'n_classes': 18,\n    'warmup_k': 5,\n    'lambda_prior': 1.2,\n    'high_conf_threshold': 0.9,\n    'group_map': group_map,\n    'group_targets': group_targets,\n    'group_tolerances': group_tolerances,\n    'apply_global_adjust': True,\n    'ema_on': True,\n    'ema_beta': 0.01,\n    'gamma_deficit': 0.9,\n    'seed': int(123),\n    'aggregated_logits': True,\n    'aggregated_temperature': 1.5,\n    'penalty_strength': 1.3, # binary penalty\n}\n\n#out = pp.process_stream_last_only(agg_logits, binary_predictions=binary_predictions)\n#labels_pp = out['last_labels_per_t']\n\n# process_stream_last_only accepts optional kappa and binary predictions for penalties\n# params based on your CV","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-09-03T08:13:28.997858Z","iopub.execute_input":"2025-09-03T08:13:28.99824Z","iopub.status.idle":"2025-09-03T08:13:29.021333Z","shell.execute_reply.started":"2025-09-03T08:13:28.998209Z","shell.execute_reply":"2025-09-03T08:13:29.020272Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}