chore: checkpoint PROTEAN research planning and architecture
This commit is contained in:
commit
2cc6a9859e
22 files changed
+2138
No files matched your search
@@ -0,0 +1,10 @@
|
||||
"""PROTEAN research package.
|
||||
|
||||
Server-side prototype aggregation study on top of the PROTEAN federated
|
||||
intrusion-detection baseline (Chennoufi et al., 2025, arXiv:2507.05524).
|
||||
|
||||
The authoritative research specification is
|
||||
``planningawal/PROTEAN_Revisi_Lurus.md`` (immutable, read-only).
|
||||
"""
|
||||
|
||||
__version__ = "0.0.1"
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Server-side prototype aggregation.
|
||||
|
||||
This package is the single locus of the proposed modification. Local model
|
||||
training, the embedding architecture, the loss terms and the inference rule are
|
||||
intentionally left untouched (see docs/research/IMPLEMENTATION_PLAN.md).
|
||||
"""
|
||||
|
||||
from .prototype_aggregation import (
|
||||
AggregationMode,
|
||||
aggregate_prototypes,
|
||||
aggregation_weights,
|
||||
beta_weights,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AggregationMode",
|
||||
"aggregate_prototypes",
|
||||
"aggregation_weights",
|
||||
"beta_weights",
|
||||
]
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Pure server-side prototype aggregation.
|
||||
|
||||
Formulation under study
|
||||
-----------------------
|
||||
For a class ``j`` with contributing-client set ``S_j = {i | N_ij > 0 and C_ij exists}``::
|
||||
|
||||
C_bar_j(beta) = sum_{i in S_j} N_ij**beta * C_ij
|
||||
--------------------------------
|
||||
sum_{i in S_j} N_ij**beta
|
||||
|
||||
with candidate ``beta in {0.0, 0.25, 0.5, 0.75, 1.0}``.
|
||||
|
||||
The functions in this module are pure and dependency-free so they can be unit
|
||||
tested in a bare interpreter. They implement only the two *unambiguous* modes:
|
||||
|
||||
``equal_available``
|
||||
equal weight for every contributing client in ``S_j`` (``beta == 0`` control).
|
||||
|
||||
``support_weighted``
|
||||
``w_ij = N_ij ** beta`` normalized over ``S_j`` (the proposed mechanism).
|
||||
|
||||
``original_protean`` is deliberately NOT implemented here. The published
|
||||
Equation (2) writes ``(1/M) * sum_{i=1}^{M} C_ij`` but the paper does not define
|
||||
what happens to ``C_ij`` when ``N_ij == 0`` (omitted / zero vector / stale value /
|
||||
different denominator). Encoding any choice here would silently assume baseline
|
||||
semantics that must first be verified from official code. See
|
||||
``docs/baseline/PROTEAN_BASELINE_INVESTIGATION.md``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from enum import Enum
|
||||
from typing import Optional, Sequence
|
||||
|
||||
Vector = Sequence[float]
|
||||
|
||||
|
||||
class AggregationMode(str, Enum):
|
||||
"""Supported prototype-aggregation modes."""
|
||||
|
||||
ORIGINAL_PROTEAN = "original_protean"
|
||||
SUPPORT_WEIGHTED = "support_weighted"
|
||||
EQUAL_AVAILABLE = "equal_available"
|
||||
|
||||
|
||||
def _validate_beta(beta: Optional[float]) -> float:
|
||||
if beta is None:
|
||||
raise ValueError("beta is required")
|
||||
beta = float(beta)
|
||||
if math.isnan(beta) or math.isinf(beta):
|
||||
raise ValueError(f"beta must be finite, got {beta!r}")
|
||||
if not 0.0 <= beta <= 1.0:
|
||||
raise ValueError(f"beta must be in the closed interval [0, 1], got {beta!r}")
|
||||
return beta
|
||||
|
||||
|
||||
def _validate_supports(supports: Optional[Sequence[float]]) -> list[float]:
|
||||
if supports is None:
|
||||
raise ValueError("supports (N_ij) are required")
|
||||
result = [float(n) for n in supports]
|
||||
if not result:
|
||||
raise ValueError("supports must not be empty")
|
||||
for n in result:
|
||||
if math.isnan(n) or math.isinf(n) or n <= 0.0:
|
||||
raise ValueError(
|
||||
"supports must be finite and strictly positive: S_j contains "
|
||||
f"only clients with N_ij > 0, got {n!r}"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _validate_vectors(prototypes: Sequence[Vector]) -> int:
|
||||
if not prototypes:
|
||||
raise ValueError("at least one prototype is required")
|
||||
if len(prototypes) == 1 and len(prototypes[0]) == 0:
|
||||
raise ValueError("prototype vectors must be non-empty")
|
||||
dim = len(prototypes[0])
|
||||
if dim == 0:
|
||||
raise ValueError("prototype vectors must be non-empty")
|
||||
for vec in prototypes:
|
||||
if len(vec) != dim:
|
||||
raise ValueError(
|
||||
f"all prototypes must share the same dimension, got {len(vec)} vs {dim}"
|
||||
)
|
||||
for value in vec:
|
||||
if not math.isfinite(float(value)):
|
||||
raise ValueError(f"prototype values must be finite, got {value!r}")
|
||||
return dim
|
||||
|
||||
|
||||
def beta_weights(supports: Sequence[float], beta: float) -> list[float]:
|
||||
"""Return the unnormalized weights ``N_ij ** beta``."""
|
||||
beta = _validate_beta(beta)
|
||||
return [n ** beta for n in _validate_supports(supports)]
|
||||
|
||||
|
||||
def aggregation_weights(
|
||||
prototypes: Sequence[Vector],
|
||||
mode: AggregationMode | str,
|
||||
beta: Optional[float] = None,
|
||||
supports: Optional[Sequence[float]] = None,
|
||||
) -> list[float]:
|
||||
"""Return normalized aggregation weights that sum to 1.
|
||||
|
||||
``prototypes`` is used for length/dimension validation and to mirror the
|
||||
required shape of the weight vector. Weights are returned in the same client
|
||||
order as ``prototypes``.
|
||||
"""
|
||||
_validate_vectors(prototypes)
|
||||
mode = AggregationMode(mode)
|
||||
|
||||
if mode is AggregationMode.ORIGINAL_PROTEAN:
|
||||
raise NotImplementedError(
|
||||
"original_protean aggregation semantics are not yet verified. "
|
||||
"Missing-class behavior (N_ij == 0) is undefined in the paper and no "
|
||||
"official implementation was found. Verify the baseline first; see "
|
||||
"docs/baseline/PROTEAN_BASELINE_INVESTIGATION.md."
|
||||
)
|
||||
|
||||
if mode is AggregationMode.SUPPORT_WEIGHTED:
|
||||
weights = beta_weights(_validate_supports(supports), _validate_beta(beta))
|
||||
else: # EQUAL_AVAILABLE
|
||||
weights = [1.0] * len(prototypes)
|
||||
|
||||
total = sum(weights)
|
||||
if not math.isfinite(total) or total <= 0.0:
|
||||
raise ValueError(f"aggregation weights must sum to a positive value, got {total!r}")
|
||||
return [w / total for w in weights]
|
||||
|
||||
|
||||
def aggregate_prototypes(
|
||||
prototypes: Sequence[Vector],
|
||||
mode: AggregationMode | str,
|
||||
beta: Optional[float] = None,
|
||||
supports: Optional[Sequence[float]] = None,
|
||||
) -> list[float]:
|
||||
"""Aggregate ``C_ij`` for one class into ``C_bar_j``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
prototypes:
|
||||
Local prototypes ``C_ij`` for the contributing clients in ``S_j``.
|
||||
mode:
|
||||
One of :class:`AggregationMode`.
|
||||
beta:
|
||||
Weighting exponent, required for ``support_weighted`` (and meaningful
|
||||
for ``equal_available`` as a documented ``beta == 0`` equivalent).
|
||||
supports:
|
||||
Sample counts ``N_ij`` (> 0), required for ``support_weighted``.
|
||||
"""
|
||||
dim = _validate_vectors(prototypes)
|
||||
if supports is not None and len(supports) != len(prototypes):
|
||||
raise ValueError(
|
||||
f"supports length ({len(supports)}) must match prototypes "
|
||||
f"({len(prototypes)})"
|
||||
)
|
||||
weights = aggregation_weights(prototypes, mode, beta=beta, supports=supports)
|
||||
result = [0.0] * dim
|
||||
for weight, vec in zip(weights, prototypes):
|
||||
for d in range(dim):
|
||||
result[d] += weight * float(vec[d])
|
||||
return result
|
||||
@@ -0,0 +1 @@
|
||||
"""Utility helpers (configuration, path resolution, source integrity)."""
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Centralized configuration loading and path resolution.
|
||||
|
||||
Design rules
|
||||
------------
|
||||
* Dataset paths are never hardcoded in training logic.
|
||||
* Raw datasets live outside the repository and are read-only.
|
||||
* Resolution precedence for a raw dataset path:
|
||||
1. environment variable named in ``env_override``
|
||||
2. ``raw_path`` in ``configs/datasets/datasets.yaml`` (``~`` expanded)
|
||||
* Generated artifacts must resolve under the configured ``generated_root``.
|
||||
|
||||
PyYAML is required to load the YAML configs. It is intentionally imported lazily
|
||||
so that dependency-free helpers (e.g. pure aggregation maths) can be tested in a
|
||||
bare Python interpreter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
DEFAULT_DATASETS_CONFIG = REPO_ROOT / "configs" / "datasets" / "datasets.yaml"
|
||||
|
||||
|
||||
def repo_root() -> Path:
|
||||
"""Absolute path of the repository root."""
|
||||
return REPO_ROOT
|
||||
|
||||
|
||||
def _require_yaml():
|
||||
try:
|
||||
import yaml # type: ignore
|
||||
except ImportError as exc: # pragma: no cover - environment dependent
|
||||
raise ImportError(
|
||||
"PyYAML is required to load configuration files. "
|
||||
"Install project dependencies (see pyproject.toml), e.g. with "
|
||||
"`uv sync` or `pip install pyyaml`."
|
||||
) from exc
|
||||
return yaml
|
||||
|
||||
|
||||
def load_yaml(path: str | os.PathLike[str]) -> Any:
|
||||
"""Load a YAML document from *path*."""
|
||||
path = Path(path)
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
return _require_yaml().safe_load(handle)
|
||||
|
||||
|
||||
def expand_path(value: str | os.PathLike[str], base: str | os.PathLike[str] | None = None) -> Path:
|
||||
"""Expand ``~`` and resolve *value* relative to *base* (default: repo root)."""
|
||||
path = Path(os.path.expanduser(str(value)))
|
||||
if not path.is_absolute():
|
||||
path = Path(base or REPO_ROOT) / path
|
||||
return path.resolve()
|
||||
|
||||
|
||||
def load_datasets_config(
|
||||
path: str | os.PathLike[str] | None = None,
|
||||
) -> Mapping[str, Any]:
|
||||
"""Load ``datasets.yaml`` (or an explicit override)."""
|
||||
return load_yaml(path or DEFAULT_DATASETS_CONFIG)
|
||||
|
||||
|
||||
def resolve_raw_path(
|
||||
dataset_cfg: Mapping[str, Any],
|
||||
) -> Path:
|
||||
"""Resolve the raw CSV path for one dataset entry.
|
||||
|
||||
Precedence: environment override, then ``raw_path``. The path is expanded
|
||||
and resolved but *not* checked for existence here so callers can give their
|
||||
own diagnostics.
|
||||
"""
|
||||
env_name = dataset_cfg.get("env_override")
|
||||
if env_name and os.environ.get(env_name):
|
||||
return expand_path(os.environ[env_name])
|
||||
if not dataset_cfg.get("raw_path"):
|
||||
raise KeyError("dataset config is missing 'raw_path'")
|
||||
return expand_path(dataset_cfg["raw_path"])
|
||||
|
||||
|
||||
def generated_root(
|
||||
datasets_cfg: Mapping[str, Any] | None = None,
|
||||
) -> Path:
|
||||
"""Root directory for all generated artifacts inside the repository."""
|
||||
cfg = datasets_cfg or load_datasets_config()
|
||||
root = expand_path(cfg.get("generated_root", "artifacts"))
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
Reference in new issue
Block a user