prometheusnn 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- prometheus/__init__.py +1 -0
- prometheus/core/__init__.py +0 -0
- prometheus/core/config.py +51 -0
- prometheus/core/embedding.py +61 -0
- prometheus/core/logging.py +24 -0
- prometheus/core/lru.py +47 -0
- prometheus/core/mcp_safety.py +49 -0
- prometheus/core/responses.py +61 -0
- prometheus/data/__init__.py +0 -0
- prometheus/data/loader.py +267 -0
- prometheus/data/splits.py +62 -0
- prometheus/data/taxonomy.py +143 -0
- prometheus/inference/__init__.py +0 -0
- prometheus/inference/beam_search.py +337 -0
- prometheus/inference/novelty.py +150 -0
- prometheus/inference/predictor.py +164 -0
- prometheus/inference/router.py +175 -0
- prometheus/inference/summary.py +577 -0
- prometheus/insights.py +360 -0
- prometheus/intake/__init__.py +36 -0
- prometheus/intake/detector.py +202 -0
- prometheus/intake/models.py +92 -0
- prometheus/intake/profiler.py +117 -0
- prometheus/intake/recommender.py +179 -0
- prometheus/intake/scoper.py +294 -0
- prometheus/py.typed +0 -0
- prometheus/server/INSTRUCTIONS.md +184 -0
- prometheus/server/__init__.py +138 -0
- prometheus/server/tools/__init__.py +13 -0
- prometheus/server/tools/evaluation.py +283 -0
- prometheus/server/tools/inference.py +296 -0
- prometheus/server/tools/intake.py +98 -0
- prometheus/server/tools/management.py +185 -0
- prometheus/server/tools/training.py +261 -0
- prometheus/storage/__init__.py +0 -0
- prometheus/storage/model_store.py +218 -0
- prometheus/training/__init__.py +0 -0
- prometheus/training/calibration.py +141 -0
- prometheus/training/cascade.py +87 -0
- prometheus/training/classifier.py +74 -0
- prometheus/training/noise.py +100 -0
- prometheus/training/trainer.py +275 -0
- prometheusnn-0.1.0.dist-info/METADATA +166 -0
- prometheusnn-0.1.0.dist-info/RECORD +47 -0
- prometheusnn-0.1.0.dist-info/WHEEL +4 -0
- prometheusnn-0.1.0.dist-info/entry_points.txt +2 -0
- prometheusnn-0.1.0.dist-info/licenses/LICENSE +21 -0
prometheus/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
|
File without changes
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Configuration and directory management."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class PrometheusConfig:
|
|
10
|
+
"""Global configuration for Prometheus."""
|
|
11
|
+
|
|
12
|
+
base_dir: Path = field(
|
|
13
|
+
default_factory=lambda: Path(os.environ.get("PROMETHEUS_HOME", Path.home() / ".prometheus"))
|
|
14
|
+
)
|
|
15
|
+
default_embedding_model: str = field(
|
|
16
|
+
default_factory=lambda: os.environ.get("PROMETHEUS_EMBEDDING_MODEL", "all-MiniLM-L6-v2")
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
def __post_init__(self):
|
|
20
|
+
self.base_dir = Path(self.base_dir)
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def models_dir(self) -> Path:
|
|
24
|
+
return self.base_dir / "models"
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def feedback_dir(self) -> Path:
|
|
28
|
+
return self.base_dir / "feedback"
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def logs_dir(self) -> Path:
|
|
32
|
+
return self.base_dir / "logs"
|
|
33
|
+
|
|
34
|
+
def ensure_dirs(self):
|
|
35
|
+
"""Create all required directories."""
|
|
36
|
+
self.models_dir.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
self.feedback_dir.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
self.logs_dir.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
|
|
40
|
+
def model_path(self, model_name: str) -> Path:
|
|
41
|
+
return self.models_dir / model_name
|
|
42
|
+
|
|
43
|
+
def list_models(self) -> list[str]:
|
|
44
|
+
"""List all saved model names."""
|
|
45
|
+
if not self.models_dir.exists():
|
|
46
|
+
return []
|
|
47
|
+
return sorted(
|
|
48
|
+
d.name
|
|
49
|
+
for d in self.models_dir.iterdir()
|
|
50
|
+
if d.is_dir() and (d / "manifest.json").exists()
|
|
51
|
+
)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Thread-safe SentenceTransformer LRU cache and encoding utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from prometheus.core.lru import LRUCache
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from sentence_transformers import SentenceTransformer
|
|
14
|
+
|
|
15
|
+
_MODEL_CACHE_SIZE = 4
|
|
16
|
+
|
|
17
|
+
_lock = threading.Lock()
|
|
18
|
+
_models: LRUCache[str, SentenceTransformer] = LRUCache(maxsize=_MODEL_CACHE_SIZE)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_embedding_model(model_name: str) -> SentenceTransformer:
|
|
22
|
+
"""Get or load a SentenceTransformer model (LRU cached, thread-safe)."""
|
|
23
|
+
cached = _models.get(model_name)
|
|
24
|
+
if cached is not None:
|
|
25
|
+
return cached
|
|
26
|
+
with _lock:
|
|
27
|
+
cached = _models.get(model_name)
|
|
28
|
+
if cached is not None:
|
|
29
|
+
return cached
|
|
30
|
+
from sentence_transformers import SentenceTransformer
|
|
31
|
+
|
|
32
|
+
model = SentenceTransformer(model_name)
|
|
33
|
+
_models.put(model_name, model)
|
|
34
|
+
return model
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def encode_texts(
|
|
38
|
+
texts: list[str],
|
|
39
|
+
model_name: str,
|
|
40
|
+
batch_size: int = 64,
|
|
41
|
+
show_progress: bool = True,
|
|
42
|
+
) -> np.ndarray:
|
|
43
|
+
"""Encode texts to embeddings using a sentence-transformer model.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
(n_texts, embedding_dim) float32 array
|
|
47
|
+
"""
|
|
48
|
+
model = get_embedding_model(model_name)
|
|
49
|
+
embeddings = model.encode(
|
|
50
|
+
texts,
|
|
51
|
+
batch_size=batch_size,
|
|
52
|
+
show_progress_bar=show_progress,
|
|
53
|
+
convert_to_numpy=True,
|
|
54
|
+
)
|
|
55
|
+
return embeddings.astype(np.float32)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_embedding_dim(model_name: str) -> int:
|
|
59
|
+
"""Get the embedding dimension for a model."""
|
|
60
|
+
model = get_embedding_model(model_name)
|
|
61
|
+
return model.get_sentence_embedding_dimension()
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Rotating file logger for PrometheusNN."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from logging.handlers import RotatingFileHandler
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def setup_logging(logs_dir: Path, level: int = logging.INFO) -> None:
|
|
9
|
+
"""Configure the prometheus logger with a rotating file handler."""
|
|
10
|
+
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
11
|
+
handler = RotatingFileHandler(
|
|
12
|
+
logs_dir / "prometheus.log",
|
|
13
|
+
maxBytes=10 * 1024 * 1024, # 10 MB
|
|
14
|
+
backupCount=3,
|
|
15
|
+
)
|
|
16
|
+
handler.setFormatter(
|
|
17
|
+
logging.Formatter(
|
|
18
|
+
"%(asctime)s %(name)s %(levelname)s %(message)s",
|
|
19
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
20
|
+
)
|
|
21
|
+
)
|
|
22
|
+
root = logging.getLogger("prometheus")
|
|
23
|
+
root.setLevel(level)
|
|
24
|
+
root.addHandler(handler)
|
prometheus/core/lru.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Tiny LRU cache with explicit key eviction.
|
|
2
|
+
|
|
3
|
+
Used for bounded in-memory caches where stdlib's ``functools.lru_cache``
|
|
4
|
+
isn't a fit (no per-key invalidation, no in-place size tuning).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections import OrderedDict
|
|
10
|
+
from typing import Generic, TypeVar
|
|
11
|
+
|
|
12
|
+
K = TypeVar("K")
|
|
13
|
+
V = TypeVar("V")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LRUCache(Generic[K, V]):
|
|
17
|
+
"""Fixed-capacity LRU cache. Not thread-safe; suitable for the MCP server's
|
|
18
|
+
single-threaded request loop.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, maxsize: int):
|
|
22
|
+
if maxsize < 1:
|
|
23
|
+
raise ValueError("maxsize must be >= 1")
|
|
24
|
+
self.maxsize = maxsize
|
|
25
|
+
self._data: OrderedDict[K, V] = OrderedDict()
|
|
26
|
+
|
|
27
|
+
def get(self, key: K) -> V | None:
|
|
28
|
+
if key in self._data:
|
|
29
|
+
self._data.move_to_end(key)
|
|
30
|
+
return self._data[key]
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
def put(self, key: K, value: V) -> None:
|
|
34
|
+
if key in self._data:
|
|
35
|
+
self._data.move_to_end(key)
|
|
36
|
+
self._data[key] = value
|
|
37
|
+
while len(self._data) > self.maxsize:
|
|
38
|
+
self._data.popitem(last=False)
|
|
39
|
+
|
|
40
|
+
def pop(self, key: K, default: V | None = None) -> V | None:
|
|
41
|
+
return self._data.pop(key, default)
|
|
42
|
+
|
|
43
|
+
def __contains__(self, key: object) -> bool:
|
|
44
|
+
return key in self._data
|
|
45
|
+
|
|
46
|
+
def __len__(self) -> int:
|
|
47
|
+
return len(self._data)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Decorator that wraps MCP tools with uniform error handling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from prometheus.core.responses import internal_error, validation_error
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ToolValidationError(Exception):
|
|
16
|
+
"""Raise from inside a tool to return a validation_error response.
|
|
17
|
+
|
|
18
|
+
The mcp_tool_safe decorator catches this and converts it to the proper
|
|
19
|
+
response shape. Use this for bad-input failures (missing file, bad column
|
|
20
|
+
name, etc.) instead of returning validation_error() directly.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, message: str, field: str | None = None):
|
|
24
|
+
self.message = message
|
|
25
|
+
self.field = field
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def mcp_tool_safe(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
30
|
+
"""Wrap an MCP tool with logging + uniform exception handling.
|
|
31
|
+
|
|
32
|
+
Catches:
|
|
33
|
+
- ToolValidationError → validation_error response (with field)
|
|
34
|
+
- Any other Exception → internal_error response, logged with traceback
|
|
35
|
+
|
|
36
|
+
Apply *inside* @mcp.tool() so the registered name and signature stay correct.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
@functools.wraps(func)
|
|
40
|
+
def wrapper(*args: Any, **kwargs: Any) -> dict:
|
|
41
|
+
try:
|
|
42
|
+
return func(*args, **kwargs)
|
|
43
|
+
except ToolValidationError as e:
|
|
44
|
+
return validation_error(e.message, field=e.field)
|
|
45
|
+
except Exception as e:
|
|
46
|
+
logger.error("%s failed: %s", func.__name__, e, exc_info=True)
|
|
47
|
+
return internal_error(str(e))
|
|
48
|
+
|
|
49
|
+
return wrapper
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Typed MCP response envelopes.
|
|
2
|
+
|
|
3
|
+
Three discriminated variants — ``status`` field tells the client which one:
|
|
4
|
+
|
|
5
|
+
- ``SuccessResponse`` (status="success") — wraps tool output payload
|
|
6
|
+
- ``ErrorResponse`` (status="error") — internal/unexpected error
|
|
7
|
+
- ``ValidationErrorResponse`` (status="validation_error") — bad input
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Literal
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, ConfigDict
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SuccessResponse(BaseModel):
|
|
18
|
+
"""Successful tool response. Extra fields from the tool's payload are merged in."""
|
|
19
|
+
|
|
20
|
+
model_config = ConfigDict(extra="allow")
|
|
21
|
+
|
|
22
|
+
status: Literal["success"] = "success"
|
|
23
|
+
message: str | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ErrorResponse(BaseModel):
|
|
27
|
+
"""Internal error response — used when a tool raises unexpectedly."""
|
|
28
|
+
|
|
29
|
+
status: Literal["error"] = "error"
|
|
30
|
+
error: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ValidationErrorResponse(BaseModel):
|
|
34
|
+
"""Validation error response — used when tool inputs fail to validate."""
|
|
35
|
+
|
|
36
|
+
status: Literal["validation_error"] = "validation_error"
|
|
37
|
+
error: str
|
|
38
|
+
field: str | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def success_response(
|
|
42
|
+
data: dict[str, Any] | BaseModel | None = None,
|
|
43
|
+
message: str | None = None,
|
|
44
|
+
) -> dict[str, Any]:
|
|
45
|
+
"""Build a success response. Pass a dict or Pydantic model for payload, or None for message-only ack."""
|
|
46
|
+
payload: dict[str, Any] = {}
|
|
47
|
+
if isinstance(data, BaseModel):
|
|
48
|
+
payload = data.model_dump(exclude_none=True)
|
|
49
|
+
elif isinstance(data, dict):
|
|
50
|
+
payload = data
|
|
51
|
+
return SuccessResponse(message=message, **payload).model_dump(exclude_none=True)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def internal_error(message: str) -> dict[str, Any]:
|
|
55
|
+
"""Build an internal error response. Stack traces are logged server-side, not returned."""
|
|
56
|
+
return ErrorResponse(error=message).model_dump(exclude_none=True)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def validation_error(message: str, field: str | None = None) -> dict[str, Any]:
|
|
60
|
+
"""Build a validation error response."""
|
|
61
|
+
return ValidationErrorResponse(error=message, field=field).model_dump(exclude_none=True)
|
|
File without changes
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""CSV/JSON data loading, taxonomy extraction, embedding, and splitting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pandas as pd
|
|
9
|
+
from sklearn.model_selection import train_test_split
|
|
10
|
+
|
|
11
|
+
from prometheus.core.embedding import encode_texts
|
|
12
|
+
from prometheus.data.splits import DataSplit
|
|
13
|
+
from prometheus.data.taxonomy import TaxonomyInfo
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_dataframe(file_path: str | Path) -> pd.DataFrame:
|
|
17
|
+
"""Load a CSV or JSON file into a DataFrame."""
|
|
18
|
+
path = Path(file_path)
|
|
19
|
+
if path.suffix.lower() == ".json":
|
|
20
|
+
return pd.read_json(path)
|
|
21
|
+
return pd.read_csv(path)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def validate_columns(
|
|
25
|
+
df: pd.DataFrame,
|
|
26
|
+
text_column: str,
|
|
27
|
+
level_columns: list[str],
|
|
28
|
+
) -> list[str]:
|
|
29
|
+
"""Validate that required columns exist. Returns list of warnings."""
|
|
30
|
+
warnings = []
|
|
31
|
+
missing = []
|
|
32
|
+
|
|
33
|
+
if text_column not in df.columns:
|
|
34
|
+
missing.append(text_column)
|
|
35
|
+
for col in level_columns:
|
|
36
|
+
if col not in df.columns:
|
|
37
|
+
missing.append(col)
|
|
38
|
+
|
|
39
|
+
if missing:
|
|
40
|
+
raise ValueError(f"Missing columns: {missing}. Available: {list(df.columns)}")
|
|
41
|
+
|
|
42
|
+
# Check for missing values
|
|
43
|
+
all_cols = [text_column] + level_columns
|
|
44
|
+
null_counts = df[all_cols].isnull().sum()
|
|
45
|
+
for col, count in null_counts.items():
|
|
46
|
+
if count > 0:
|
|
47
|
+
warnings.append(f"Column '{col}' has {count} missing values")
|
|
48
|
+
|
|
49
|
+
return warnings
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def check_hierarchy_consistency(
|
|
53
|
+
df: pd.DataFrame,
|
|
54
|
+
level_columns: list[str],
|
|
55
|
+
) -> list[str]:
|
|
56
|
+
"""Check that child labels map to consistent parents.
|
|
57
|
+
|
|
58
|
+
Returns list of warnings for inconsistencies.
|
|
59
|
+
"""
|
|
60
|
+
warnings = []
|
|
61
|
+
for i in range(1, len(level_columns)):
|
|
62
|
+
child_col = level_columns[i]
|
|
63
|
+
parent_col = level_columns[i - 1]
|
|
64
|
+
|
|
65
|
+
# Group child values by parent
|
|
66
|
+
child_parents = df.groupby(child_col)[parent_col].nunique()
|
|
67
|
+
multi_parent = child_parents[child_parents > 1]
|
|
68
|
+
|
|
69
|
+
if len(multi_parent) > 0:
|
|
70
|
+
for child_val, n_parents in multi_parent.items():
|
|
71
|
+
parents = df[df[child_col] == child_val][parent_col].unique()
|
|
72
|
+
warnings.append(
|
|
73
|
+
f"'{child_val}' at level '{child_col}' maps to {n_parents} "
|
|
74
|
+
f"parents at '{parent_col}': {list(parents)}"
|
|
75
|
+
)
|
|
76
|
+
return warnings
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def extract_taxonomy(
|
|
80
|
+
df: pd.DataFrame,
|
|
81
|
+
model_name: str,
|
|
82
|
+
text_column: str,
|
|
83
|
+
level_columns: list[str],
|
|
84
|
+
embedding_model: str,
|
|
85
|
+
) -> TaxonomyInfo:
|
|
86
|
+
"""Extract taxonomy structure from a DataFrame."""
|
|
87
|
+
level_classes = []
|
|
88
|
+
for col in level_columns:
|
|
89
|
+
classes = sorted(df[col].dropna().unique().tolist())
|
|
90
|
+
level_classes.append(classes)
|
|
91
|
+
|
|
92
|
+
return TaxonomyInfo.build(
|
|
93
|
+
name=model_name,
|
|
94
|
+
text_column=text_column,
|
|
95
|
+
level_columns=level_columns,
|
|
96
|
+
level_classes=level_classes,
|
|
97
|
+
embedding_model=embedding_model,
|
|
98
|
+
total_samples=len(df),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def prepare_data(
|
|
103
|
+
file_path: str | Path,
|
|
104
|
+
text_column: str,
|
|
105
|
+
level_columns: list[str],
|
|
106
|
+
model_name: str,
|
|
107
|
+
embedding_model: str,
|
|
108
|
+
train_ratio: float = 0.80,
|
|
109
|
+
val_ratio: float = 0.10,
|
|
110
|
+
test_ratio: float = 0.10,
|
|
111
|
+
random_state: int = 42,
|
|
112
|
+
show_progress: bool = True,
|
|
113
|
+
) -> tuple[TaxonomyInfo, DataSplit, DataSplit, DataSplit, list[str]]:
|
|
114
|
+
"""Full data preparation pipeline: load, validate, embed, split.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
(taxonomy, train_split, val_split, test_split, warnings)
|
|
118
|
+
"""
|
|
119
|
+
df = load_dataframe(file_path)
|
|
120
|
+
warnings = validate_columns(df, text_column, level_columns)
|
|
121
|
+
|
|
122
|
+
# Drop rows with missing values in required columns
|
|
123
|
+
all_cols = [text_column] + level_columns
|
|
124
|
+
clean_df = df.dropna(subset=all_cols).reset_index(drop=True)
|
|
125
|
+
if len(clean_df) < len(df):
|
|
126
|
+
dropped = len(df) - len(clean_df)
|
|
127
|
+
warnings.append(f"Dropped {dropped} rows with missing values")
|
|
128
|
+
|
|
129
|
+
warnings.extend(check_hierarchy_consistency(clean_df, level_columns))
|
|
130
|
+
|
|
131
|
+
# Extract taxonomy
|
|
132
|
+
taxonomy = extract_taxonomy(clean_df, model_name, text_column, level_columns, embedding_model)
|
|
133
|
+
|
|
134
|
+
# Build parent→valid_children map for hierarchical masking
|
|
135
|
+
if len(level_columns) > 1:
|
|
136
|
+
pcm: dict[tuple[int, int], list[int]] = {}
|
|
137
|
+
for depth in range(1, len(level_columns)):
|
|
138
|
+
parent_col = level_columns[depth - 1]
|
|
139
|
+
child_col = level_columns[depth]
|
|
140
|
+
parent_level = taxonomy.get_level(depth - 1)
|
|
141
|
+
child_level = taxonomy.get_level(depth)
|
|
142
|
+
for parent_cls in parent_level.classes:
|
|
143
|
+
child_vals = clean_df.loc[clean_df[parent_col] == parent_cls, child_col].unique()
|
|
144
|
+
child_indices = sorted(child_level.encode(c) for c in child_vals)
|
|
145
|
+
parent_idx = parent_level.encode(parent_cls)
|
|
146
|
+
pcm[(depth, parent_idx)] = child_indices
|
|
147
|
+
taxonomy.parent_child_map = pcm
|
|
148
|
+
|
|
149
|
+
# Encode texts
|
|
150
|
+
texts = clean_df[text_column].tolist()
|
|
151
|
+
embeddings = encode_texts(texts, embedding_model, show_progress=show_progress)
|
|
152
|
+
|
|
153
|
+
# Encode labels by depth
|
|
154
|
+
labels_by_depth = {}
|
|
155
|
+
for depth, level in enumerate(taxonomy.levels):
|
|
156
|
+
labels_by_depth[depth] = np.array([level.encode(val) for val in clean_df[level.name]])
|
|
157
|
+
|
|
158
|
+
# Split — handle rare classes that can't be stratified
|
|
159
|
+
indices = np.arange(len(clean_df))
|
|
160
|
+
root_labels = labels_by_depth[0]
|
|
161
|
+
|
|
162
|
+
# Find classes too rare to stratify (need ≥2 samples in val+test,
|
|
163
|
+
# which means ≥ ceil(2 / val_test_ratio) total samples)
|
|
164
|
+
val_test_ratio = val_ratio + test_ratio
|
|
165
|
+
unique_labels, label_counts = np.unique(root_labels, return_counts=True)
|
|
166
|
+
min_samples_for_stratify = max(2, int(np.ceil(2 / val_test_ratio)))
|
|
167
|
+
rare_labels = set(unique_labels[label_counts < min_samples_for_stratify])
|
|
168
|
+
|
|
169
|
+
if rare_labels:
|
|
170
|
+
# Rare classes go straight into training; stratify the rest
|
|
171
|
+
rare_mask = np.isin(root_labels, list(rare_labels))
|
|
172
|
+
rare_idx = indices[rare_mask]
|
|
173
|
+
stratifiable_idx = indices[~rare_mask]
|
|
174
|
+
stratifiable_labels = root_labels[stratifiable_idx]
|
|
175
|
+
|
|
176
|
+
strat_train_idx, val_test_idx = train_test_split(
|
|
177
|
+
stratifiable_idx,
|
|
178
|
+
test_size=val_test_ratio,
|
|
179
|
+
random_state=random_state,
|
|
180
|
+
stratify=stratifiable_labels,
|
|
181
|
+
)
|
|
182
|
+
train_idx = np.concatenate([strat_train_idx, rare_idx])
|
|
183
|
+
|
|
184
|
+
test_proportion = test_ratio / val_test_ratio
|
|
185
|
+
val_idx, test_idx = train_test_split(
|
|
186
|
+
val_test_idx,
|
|
187
|
+
test_size=test_proportion,
|
|
188
|
+
random_state=random_state,
|
|
189
|
+
stratify=root_labels[val_test_idx],
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
rare_class_names = [taxonomy.levels[0].decode(int(lbl)) for lbl in rare_labels]
|
|
193
|
+
warnings.append(
|
|
194
|
+
f"Rare root classes forced into training set (too few samples to stratify): "
|
|
195
|
+
f"{rare_class_names}"
|
|
196
|
+
)
|
|
197
|
+
else:
|
|
198
|
+
# Standard stratified split
|
|
199
|
+
train_idx, val_test_idx = train_test_split(
|
|
200
|
+
indices,
|
|
201
|
+
test_size=val_test_ratio,
|
|
202
|
+
random_state=random_state,
|
|
203
|
+
stratify=root_labels,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
test_proportion = test_ratio / val_test_ratio
|
|
207
|
+
val_idx, test_idx = train_test_split(
|
|
208
|
+
val_test_idx,
|
|
209
|
+
test_size=test_proportion,
|
|
210
|
+
random_state=random_state,
|
|
211
|
+
stratify=root_labels[val_test_idx],
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
def make_split(idx: np.ndarray) -> DataSplit:
|
|
215
|
+
return DataSplit(
|
|
216
|
+
embeddings=embeddings[idx],
|
|
217
|
+
labels_by_depth={d: lbls[idx] for d, lbls in labels_by_depth.items()},
|
|
218
|
+
texts=[texts[i] for i in idx],
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
return (
|
|
222
|
+
taxonomy,
|
|
223
|
+
make_split(train_idx),
|
|
224
|
+
make_split(val_idx),
|
|
225
|
+
make_split(test_idx),
|
|
226
|
+
warnings,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def analyze_data(
|
|
231
|
+
file_path: str | Path,
|
|
232
|
+
text_column: str,
|
|
233
|
+
level_columns: list[str],
|
|
234
|
+
) -> dict:
|
|
235
|
+
"""Analyze training data without embedding. Returns summary stats."""
|
|
236
|
+
df = load_dataframe(file_path)
|
|
237
|
+
warnings = validate_columns(df, text_column, level_columns)
|
|
238
|
+
|
|
239
|
+
all_cols = [text_column] + level_columns
|
|
240
|
+
clean_df = df.dropna(subset=all_cols).reset_index(drop=True)
|
|
241
|
+
if len(clean_df) < len(df):
|
|
242
|
+
warnings.append(f"{len(df) - len(clean_df)} rows have missing values")
|
|
243
|
+
|
|
244
|
+
warnings.extend(check_hierarchy_consistency(clean_df, level_columns))
|
|
245
|
+
|
|
246
|
+
level_stats = []
|
|
247
|
+
for col in level_columns:
|
|
248
|
+
counts = clean_df[col].value_counts()
|
|
249
|
+
level_stats.append(
|
|
250
|
+
{
|
|
251
|
+
"column": col,
|
|
252
|
+
"num_classes": len(counts),
|
|
253
|
+
"class_counts": counts.to_dict(),
|
|
254
|
+
"min_count": int(counts.min()),
|
|
255
|
+
"max_count": int(counts.max()),
|
|
256
|
+
}
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
"total_rows": len(df),
|
|
261
|
+
"clean_rows": len(clean_df),
|
|
262
|
+
"text_column": text_column,
|
|
263
|
+
"num_levels": len(level_columns),
|
|
264
|
+
"level_stats": level_stats,
|
|
265
|
+
"warnings": warnings,
|
|
266
|
+
"text_sample": clean_df[text_column].head(5).tolist(),
|
|
267
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Data splits and batch iteration for N-level training."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class DataSplit:
|
|
12
|
+
"""A split of data for training/validation/calibration."""
|
|
13
|
+
|
|
14
|
+
embeddings: np.ndarray # (N, embed_dim)
|
|
15
|
+
labels_by_depth: dict[int, np.ndarray] # {0: (N,), 1: (N,), ...}
|
|
16
|
+
texts: list[str] | None = None
|
|
17
|
+
|
|
18
|
+
def __len__(self) -> int:
|
|
19
|
+
return len(self.embeddings)
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def num_levels(self) -> int:
|
|
23
|
+
return len(self.labels_by_depth)
|
|
24
|
+
|
|
25
|
+
def get_labels(self, depth: int) -> np.ndarray:
|
|
26
|
+
return self.labels_by_depth[depth]
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def leaf_labels(self) -> np.ndarray:
|
|
30
|
+
max_depth = max(self.labels_by_depth.keys())
|
|
31
|
+
return self.labels_by_depth[max_depth]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class BatchIterator:
|
|
35
|
+
"""Yields batches of (embeddings, label dicts) for training."""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
data: DataSplit,
|
|
40
|
+
batch_size: int = 64,
|
|
41
|
+
shuffle: bool = True,
|
|
42
|
+
random_state: int | None = None,
|
|
43
|
+
):
|
|
44
|
+
self.data = data
|
|
45
|
+
self.batch_size = batch_size
|
|
46
|
+
self.shuffle = shuffle
|
|
47
|
+
self.rng = np.random.default_rng(random_state)
|
|
48
|
+
|
|
49
|
+
def __len__(self) -> int:
|
|
50
|
+
return (len(self.data) + self.batch_size - 1) // self.batch_size
|
|
51
|
+
|
|
52
|
+
def __iter__(self):
|
|
53
|
+
indices = np.arange(len(self.data))
|
|
54
|
+
if self.shuffle:
|
|
55
|
+
self.rng.shuffle(indices)
|
|
56
|
+
|
|
57
|
+
for start in range(0, len(indices), self.batch_size):
|
|
58
|
+
batch_idx = indices[start : start + self.batch_size]
|
|
59
|
+
batch = {"embeddings": self.data.embeddings[batch_idx]}
|
|
60
|
+
for depth, labels in self.data.labels_by_depth.items():
|
|
61
|
+
batch[depth] = labels[batch_idx]
|
|
62
|
+
yield batch
|