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.
Files changed (47) hide show
  1. prometheus/__init__.py +1 -0
  2. prometheus/core/__init__.py +0 -0
  3. prometheus/core/config.py +51 -0
  4. prometheus/core/embedding.py +61 -0
  5. prometheus/core/logging.py +24 -0
  6. prometheus/core/lru.py +47 -0
  7. prometheus/core/mcp_safety.py +49 -0
  8. prometheus/core/responses.py +61 -0
  9. prometheus/data/__init__.py +0 -0
  10. prometheus/data/loader.py +267 -0
  11. prometheus/data/splits.py +62 -0
  12. prometheus/data/taxonomy.py +143 -0
  13. prometheus/inference/__init__.py +0 -0
  14. prometheus/inference/beam_search.py +337 -0
  15. prometheus/inference/novelty.py +150 -0
  16. prometheus/inference/predictor.py +164 -0
  17. prometheus/inference/router.py +175 -0
  18. prometheus/inference/summary.py +577 -0
  19. prometheus/insights.py +360 -0
  20. prometheus/intake/__init__.py +36 -0
  21. prometheus/intake/detector.py +202 -0
  22. prometheus/intake/models.py +92 -0
  23. prometheus/intake/profiler.py +117 -0
  24. prometheus/intake/recommender.py +179 -0
  25. prometheus/intake/scoper.py +294 -0
  26. prometheus/py.typed +0 -0
  27. prometheus/server/INSTRUCTIONS.md +184 -0
  28. prometheus/server/__init__.py +138 -0
  29. prometheus/server/tools/__init__.py +13 -0
  30. prometheus/server/tools/evaluation.py +283 -0
  31. prometheus/server/tools/inference.py +296 -0
  32. prometheus/server/tools/intake.py +98 -0
  33. prometheus/server/tools/management.py +185 -0
  34. prometheus/server/tools/training.py +261 -0
  35. prometheus/storage/__init__.py +0 -0
  36. prometheus/storage/model_store.py +218 -0
  37. prometheus/training/__init__.py +0 -0
  38. prometheus/training/calibration.py +141 -0
  39. prometheus/training/cascade.py +87 -0
  40. prometheus/training/classifier.py +74 -0
  41. prometheus/training/noise.py +100 -0
  42. prometheus/training/trainer.py +275 -0
  43. prometheusnn-0.1.0.dist-info/METADATA +166 -0
  44. prometheusnn-0.1.0.dist-info/RECORD +47 -0
  45. prometheusnn-0.1.0.dist-info/WHEEL +4 -0
  46. prometheusnn-0.1.0.dist-info/entry_points.txt +2 -0
  47. 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