trainer.py 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923
  1. # -*- coding: utf-8 -*-
  2. """
  3. Intent-Classifier Trainer.
  4. Trainiert ein Modell fuer Intent-Erkennung.
  5. Exportiert als ONNX fuer schnelle Inferenz auf Raspberry Pi (<100ms).
  6. Architektur:
  7. - Encoder: Sentence-Transformer (multilingual, ~130MB)
  8. - Classifier: 2-Layer Linear (256 hidden, dropout 0.3)
  9. - Export: encoder.onnx + classifier.onnx + tokenizer/
  10. Training auf Desktop (GPU empfohlen), Deployment auf Pi (CPU, ONNX).
  11. """
  12. from __future__ import annotations
  13. import json
  14. import os
  15. import time
  16. from dataclasses import dataclass, field
  17. from multiprocessing import Event as MPEvent, Queue
  18. from pathlib import Path
  19. from typing import Any
  20. from trixy_core.trainer.itrainer import ITrainer
  21. from trixy_core.trainer.dataclasses import (
  22. TrainerInfo,
  23. TrainingProgress,
  24. ValidationResult,
  25. FormSchema,
  26. )
  27. from trixy_core.trainer.enums import TrainerType
  28. from trixy_core.trainer.core.intent.slot_lists import SlotRegistry
  29. from trixy_core.trainer.core.intent.data_generator import (
  30. DataGenerator,
  31. IntentDefinition,
  32. TrainingSample,
  33. )
  34. from trixy_core.utils.debug import pinfo, pdebug, perror
  35. class IntentTrainer(ITrainer):
  36. """
  37. Trainer fuer Intent-Klassifikation.
  38. Sammelt Intents aus Plugin-YAML-Dateien, generiert Trainings-Daten
  39. und trainiert ein ONNX-exportierbares Modell.
  40. """
  41. def __init__(self) -> None:
  42. self._settings: dict[str, Any] = {
  43. "base_model": "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2",
  44. "samples_per_intent": 200,
  45. "epochs": 20,
  46. "batch_size": 32,
  47. "learning_rate": 2e-5,
  48. "validation_split": 0.15,
  49. "slot_lists_file": "config/slot_lists.json",
  50. "plugins_dir": "plugins",
  51. "core_nlp_dir": "trixy_core/nlp",
  52. "output_dir": "models/intent",
  53. "export_onnx": True,
  54. "filler_variants": 3,
  55. }
  56. self._dataset_config: dict[str, Any] = {
  57. "additional_examples_dir": "trainer/data/intent/examples",
  58. "negatives_file": "trainer/data/intent/negatives.txt",
  59. "processed_dir": "trainer/data/intent/processed",
  60. "fillers_file": "trainer/assets/intent/fillers_de.json",
  61. "slot_lists_file": "trainer/assets/intent/slot_lists.json",
  62. }
  63. # --- ITrainer Metadaten ---
  64. @classmethod
  65. def get_info(cls) -> TrainerInfo:
  66. return TrainerInfo(
  67. trainer_id="intent_classifier",
  68. name="Intent-Classifier",
  69. description="Trainiert ein Modell fuer Intent-Erkennung und Slot-Extraktion",
  70. trainer_type=TrainerType.CORE,
  71. version="1.0.0",
  72. model_type="intent",
  73. )
  74. def get_settings_schema(self) -> FormSchema:
  75. return FormSchema(fields=[
  76. {"name": "base_model", "label": "Basis-Modell", "field_type": "select",
  77. "default": self._settings["base_model"],
  78. "options": [
  79. "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2",
  80. "sentence-transformers/distiluse-base-multilingual-cased-v2",
  81. "sentence-transformers/all-MiniLM-L6-v2",
  82. ],
  83. "help_text": "Vortrainiertes Encoder-Modell (multilingual = Deutsch)"},
  84. {"name": "samples_per_intent", "label": "Samples pro Intent", "field_type": "number",
  85. "default": 200, "min": 50, "max": 2000,
  86. "help_text": "Anzahl generierter Trainingsbeispiele pro Intent"},
  87. {"name": "filler_variants", "label": "Fuellwort-Varianten", "field_type": "number",
  88. "default": 3, "min": 0, "max": 10,
  89. "help_text": "Varianten pro Beispiel mit Fuellwoertern (0 = deaktiviert)"},
  90. {"name": "epochs", "label": "Epochen", "field_type": "number",
  91. "default": 20, "min": 5, "max": 100},
  92. {"name": "batch_size", "label": "Batch-Groesse", "field_type": "number",
  93. "default": 32, "min": 8, "max": 128},
  94. {"name": "learning_rate", "label": "Lernrate", "field_type": "number", "is_float": True,
  95. "default": 2e-5},
  96. {"name": "export_onnx", "label": "ONNX Export", "field_type": "checkbox",
  97. "default": True, "help_text": "Modell als ONNX exportieren (fuer Pi)"},
  98. ])
  99. def get_dataset_schema(self) -> FormSchema:
  100. return FormSchema(fields=[
  101. {"name": "plugins_dir", "label": "Plugins-Verzeichnis", "field_type": "text",
  102. "default": "plugins", "help_text": "Verzeichnis mit Plugin-Ordnern"},
  103. {"name": "additional_examples_dir", "label": "Zusaetzliche Beispiele", "field_type": "text",
  104. "default": "trainer/data/intent/examples",
  105. "help_text": "Verzeichnis mit zusaetzlichen Beispiel-Dateien (.txt)"},
  106. {"name": "negatives_file", "label": "Negativ-Beispiele", "field_type": "text",
  107. "default": "trainer/data/intent/negatives.txt"},
  108. {"name": "slot_lists_file", "label": "Slot-Listen", "field_type": "text",
  109. "default": "config/slot_lists.json"},
  110. ])
  111. def get_optional_schema(self) -> FormSchema | None:
  112. return None
  113. def get_settings(self) -> dict:
  114. return dict(self._settings)
  115. def set_settings(self, settings: dict) -> None:
  116. self._settings.update(settings)
  117. def get_dataset_info(self) -> dict:
  118. """Analysiert das vorhandene Dataset."""
  119. gen = self._create_generator()
  120. gen.collect_from_yaml_files(
  121. self._settings.get("plugins_dir", "plugins"),
  122. self._settings.get("core_nlp_dir", "trixy_core/nlp"),
  123. )
  124. stats = gen.get_stats()
  125. # Bestehendes Modell pruefen
  126. model_dir = Path(self._settings.get("output_dir", "models/intent"))
  127. metafile = model_dir / "metafile.json"
  128. existing_model = None
  129. if metafile.is_file():
  130. try:
  131. with open(metafile) as f:
  132. existing_model = json.load(f)
  133. except Exception:
  134. pass
  135. return {
  136. **stats,
  137. "existing_model": existing_model,
  138. }
  139. def set_dataset_config(self, config: dict) -> None:
  140. self._dataset_config.update(config)
  141. if "plugins_dir" in config:
  142. self._settings["plugins_dir"] = config["plugins_dir"]
  143. def validate(self) -> ValidationResult:
  144. """Validiert die Trainings-Konfiguration."""
  145. errors = []
  146. # Abhaengigkeiten pruefen
  147. try:
  148. import torch
  149. except ImportError:
  150. errors.append("PyTorch nicht installiert (pip install torch)")
  151. try:
  152. from sentence_transformers import SentenceTransformer
  153. except ImportError:
  154. errors.append("sentence-transformers nicht installiert")
  155. try:
  156. import sklearn
  157. except ImportError:
  158. errors.append("scikit-learn nicht installiert")
  159. if errors:
  160. return ValidationResult(valid=False, errors=errors)
  161. # Intents pruefen
  162. gen = self._create_generator()
  163. count = gen.collect_from_yaml_files(
  164. self._settings.get("plugins_dir", "plugins"),
  165. self._settings.get("core_nlp_dir", "trixy_core/nlp"),
  166. )
  167. if count == 0:
  168. return ValidationResult(
  169. valid=False,
  170. errors=["Keine Intents gefunden — YAML-Dateien pruefen"],
  171. )
  172. return ValidationResult(valid=True)
  173. # --- Training ---
  174. def train(
  175. self,
  176. progress_queue: Queue,
  177. pause_flag: MPEvent,
  178. stop_flag: MPEvent,
  179. ) -> None:
  180. """
  181. Fuehrt das Training durch (laeuft im Subprocess).
  182. Sendet TrainingProgress ueber die Queue.
  183. Prueft stop_flag und pause_flag in Schleifen.
  184. """
  185. import torch
  186. import numpy as np
  187. from sklearn.model_selection import StratifiedKFold
  188. from sklearn.metrics import classification_report
  189. from sklearn.preprocessing import LabelEncoder
  190. total_epochs = self._settings.get("epochs", 20)
  191. def report(
  192. progress: float, message: str,
  193. epoch: int = 0, loss: float = 0.0, val_loss: float = 0.0, accuracy: float = 0.0,
  194. ) -> None:
  195. from trixy_core.trainer.enums import TrainingState
  196. progress_queue.put(TrainingProgress(
  197. state=TrainingState.TRAINING,
  198. epoch=epoch,
  199. total_epochs=total_epochs,
  200. loss=loss,
  201. val_loss=val_loss,
  202. accuracy=accuracy,
  203. message=message,
  204. ))
  205. def check_stop() -> bool:
  206. return stop_flag.is_set()
  207. def wait_if_paused() -> None:
  208. while pause_flag.is_set() and not stop_flag.is_set():
  209. time.sleep(0.5)
  210. report(0.0, "Initialisiere...")
  211. # Settings-Typen sicherstellen (TUI sendet alles als String)
  212. for int_key in ("samples_per_intent", "epochs", "batch_size"):
  213. if int_key in self._settings:
  214. try:
  215. self._settings[int_key] = int(self._settings[int_key])
  216. except (ValueError, TypeError):
  217. pass
  218. for float_key in ("learning_rate", "validation_split"):
  219. if float_key in self._settings:
  220. try:
  221. self._settings[float_key] = float(self._settings[float_key])
  222. except (ValueError, TypeError):
  223. pass
  224. if "export_onnx" in self._settings:
  225. val = self._settings["export_onnx"]
  226. if isinstance(val, str):
  227. self._settings["export_onnx"] = val.lower() in ("true", "1", "yes")
  228. # === 1. Slot-Listen laden ===
  229. report(0.05, "Lade Slot-Listen...")
  230. slot_registry = SlotRegistry()
  231. # Slot-Listen: trainer/assets/intent/ bevorzugt, Fallback config/
  232. slot_file = self._dataset_config.get(
  233. "slot_lists_file", "trainer/assets/intent/slot_lists.json",
  234. )
  235. if not os.path.isfile(slot_file):
  236. slot_file = "config/slot_lists.json" # Fallback
  237. if os.path.isfile(slot_file):
  238. slot_registry.load_from_file(slot_file)
  239. # Plugin-eigene Slot-Listen laden (plugins/*/slot_lists.json)
  240. plugins_dir = self._settings.get("plugins_dir", "plugins")
  241. if os.path.isdir(plugins_dir):
  242. from pathlib import Path
  243. for plugin_slots in sorted(Path(plugins_dir).glob("*/slot_lists.json")):
  244. loaded = slot_registry.load_from_file(plugin_slots)
  245. if loaded:
  246. report(0.05, f"Plugin-Slots: {plugin_slots.parent.name} ({loaded})")
  247. # === 2. Intents sammeln ===
  248. report(0.1, "Sammle Intent-Definitionen...")
  249. generator = self._create_generator(slot_registry)
  250. generator.collect_from_yaml_files(
  251. self._settings.get("plugins_dir", "plugins"),
  252. self._settings.get("core_nlp_dir", "trixy_core/nlp"),
  253. )
  254. # Fuellwoerter laden: trainer/assets/intent/ bevorzugt, Fallback config/
  255. fillers_file = self._dataset_config.get(
  256. "fillers_file", "trainer/assets/intent/fillers_de.json",
  257. )
  258. if not os.path.isfile(fillers_file):
  259. fillers_file = "config/fillers_de.json"
  260. generator.load_fillers(fillers_file)
  261. # Zusaetzliche Beispiele laden
  262. examples_dir = self._dataset_config.get("additional_examples_dir", "")
  263. if examples_dir and os.path.isdir(examples_dir):
  264. self._load_additional_examples(generator, examples_dir)
  265. if generator.intent_count == 0:
  266. report(1.0, "FEHLER: Keine Intents gefunden")
  267. return
  268. if check_stop():
  269. return
  270. # === 3. Trainings-Daten generieren ===
  271. report(0.15, f"Generiere Trainings-Daten ({generator.intent_count} Intents)...")
  272. samples_per_intent = self._settings.get("samples_per_intent", 200)
  273. filler_variants = self._settings.get("filler_variants", 3)
  274. samples = generator.generate(
  275. samples_per_intent=samples_per_intent,
  276. filler_variants=filler_variants,
  277. )
  278. # Trainings-Daten als lesbare Dateien exportieren
  279. processed_dir = self._dataset_config.get(
  280. "processed_dir", "trainer/data/intent/processed",
  281. )
  282. report(0.17, "Exportiere Trainings-Daten...")
  283. generator.export_to_files(samples, processed_dir)
  284. # === 4. Label-Encoding ===
  285. report(0.2, "Encode Labels...")
  286. intent_encoder = LabelEncoder()
  287. all_intents = [s.intent for s in samples]
  288. intent_labels = intent_encoder.fit_transform(all_intents)
  289. intent_names = list(intent_encoder.classes_)
  290. num_intents = len(intent_names)
  291. pinfo(f"Intent-Klassen: {num_intents}, Samples: {len(samples)}")
  292. # === 5. Sentence-Encoder laden ===
  293. report(0.25, "Lade Sentence-Encoder...")
  294. base_model = self._settings.get(
  295. "base_model",
  296. "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2",
  297. )
  298. try:
  299. from sentence_transformers import SentenceTransformer
  300. encoder = SentenceTransformer(base_model)
  301. embedding_dim = encoder.get_sentence_embedding_dimension()
  302. pinfo(f"Encoder: {base_model} (dim={embedding_dim})")
  303. except Exception as e:
  304. report(1.0, f"FEHLER: Encoder laden fehlgeschlagen: {e}")
  305. return
  306. if check_stop():
  307. return
  308. # === 6. Embeddings berechnen (mit Cache) ===
  309. report(0.3, f"Berechne Embeddings ({len(samples)} Samples)...")
  310. texts = [s.text for s in samples]
  311. from trixy_core.trainer.core.intent.embedding_cache import EmbeddingCache
  312. model_dir = Path(self._settings.get("output_dir", "models/intent"))
  313. cache = EmbeddingCache(model_dir)
  314. cache.load(base_model)
  315. def cache_progress(computed: int, total: int, cached: int) -> None:
  316. if total > 0:
  317. pct = computed / total
  318. report(0.3 + pct * 0.1, f"Embeddings: {cached} Cache, {computed}/{total} neu")
  319. embeddings = cache.get_or_compute(
  320. texts, encoder.encode, batch_size=64,
  321. progress_fn=cache_progress,
  322. )
  323. cache.save()
  324. if check_stop():
  325. return
  326. # === 7. Cross-Validation Training ===
  327. report(0.35, "Starte Cross-Validation Training...")
  328. epochs = self._settings.get("epochs", 20)
  329. batch_size = self._settings.get("batch_size", 32)
  330. lr = self._settings.get("learning_rate", 2e-5)
  331. n_splits = min(5, min(
  332. np.bincount(intent_labels).min(),
  333. len(intent_labels) // 5,
  334. ))
  335. n_splits = max(2, n_splits)
  336. skf = StratifiedKFold(n_splits=n_splits, shuffle=True, random_state=42)
  337. fold_results: list[dict] = []
  338. best_model_state = None
  339. best_val_acc = 0.0
  340. all_train_losses: list[float] = []
  341. all_val_losses: list[float] = []
  342. for fold, (train_idx, val_idx) in enumerate(skf.split(embeddings, intent_labels)):
  343. if check_stop():
  344. return
  345. wait_if_paused()
  346. report(
  347. 0.35 + (fold / n_splits) * 0.45,
  348. f"Fold {fold + 1}/{n_splits}...",
  349. )
  350. X_train = torch.FloatTensor(embeddings[train_idx])
  351. y_train = torch.LongTensor(intent_labels[train_idx])
  352. X_val = torch.FloatTensor(embeddings[val_idx])
  353. y_val = torch.LongTensor(intent_labels[val_idx])
  354. # Classifier erstellen
  355. classifier = torch.nn.Sequential(
  356. torch.nn.Linear(embedding_dim, 256),
  357. torch.nn.ReLU(),
  358. torch.nn.Dropout(0.3),
  359. torch.nn.Linear(256, num_intents),
  360. )
  361. optimizer = torch.optim.Adam(classifier.parameters(), lr=lr)
  362. loss_fn = torch.nn.CrossEntropyLoss()
  363. for epoch in range(epochs):
  364. if check_stop():
  365. return
  366. wait_if_paused()
  367. classifier.train()
  368. epoch_loss = 0.0
  369. indices = torch.randperm(len(X_train))
  370. for i in range(0, len(indices), batch_size):
  371. batch_idx = indices[i:i + batch_size]
  372. output = classifier(X_train[batch_idx])
  373. loss = loss_fn(output, y_train[batch_idx])
  374. optimizer.zero_grad()
  375. loss.backward()
  376. optimizer.step()
  377. epoch_loss += loss.item()
  378. avg_loss = epoch_loss / max(1, len(X_train) // batch_size)
  379. # Validierung
  380. classifier.eval()
  381. with torch.no_grad():
  382. val_output = classifier(X_val)
  383. val_loss = loss_fn(val_output, y_val).item()
  384. val_pred = val_output.argmax(dim=1)
  385. val_acc = (val_pred == y_val).float().mean().item()
  386. if fold == 0:
  387. all_train_losses.append(avg_loss)
  388. all_val_losses.append(val_loss)
  389. progress = 0.35 + ((fold + epoch / epochs) / n_splits) * 0.45
  390. report(progress, f"Fold {fold+1} Ep {epoch+1}/{epochs} — Acc: {val_acc:.3f}", avg_loss)
  391. # Fold-Ergebnis
  392. classifier.eval()
  393. with torch.no_grad():
  394. val_output = classifier(X_val)
  395. val_pred = val_output.argmax(dim=1).numpy()
  396. fold_acc = (val_pred == y_val.numpy()).mean()
  397. fold_results.append({"fold": fold + 1, "accuracy": float(fold_acc)})
  398. if fold_acc > best_val_acc:
  399. best_val_acc = fold_acc
  400. best_model_state = classifier.state_dict()
  401. # === 8. Per-Intent Metriken ===
  402. report(0.82, "Berechne Metriken...")
  403. # Bestes Modell auf allen Daten evaluieren
  404. classifier = torch.nn.Sequential(
  405. torch.nn.Linear(embedding_dim, 256),
  406. torch.nn.ReLU(),
  407. torch.nn.Dropout(0.3),
  408. torch.nn.Linear(256, num_intents),
  409. )
  410. classifier.load_state_dict(best_model_state)
  411. classifier.eval()
  412. with torch.no_grad():
  413. all_output = classifier(torch.FloatTensor(embeddings))
  414. all_pred = all_output.argmax(dim=1).numpy()
  415. report_str = classification_report(
  416. intent_labels, all_pred,
  417. target_names=intent_names,
  418. output_dict=False,
  419. )
  420. report_dict = classification_report(
  421. intent_labels, all_pred,
  422. target_names=intent_names,
  423. output_dict=True,
  424. )
  425. pinfo(f"Classification Report:\n{report_str}")
  426. # === 9. Modell speichern ===
  427. report(0.88, "Speichere Modell...")
  428. model_dir = Path(self._settings.get("output_dir", "models/intent"))
  429. model_dir.mkdir(parents=True, exist_ok=True)
  430. torch.save(best_model_state, model_dir / "classifier.pth")
  431. metafile = {
  432. "model_name": "intent_classifier",
  433. "base_model": base_model,
  434. "embedding_dim": embedding_dim,
  435. "intent_names": intent_names,
  436. "slot_names": list(generator._slots.slot_names),
  437. "num_intents": num_intents,
  438. "train_samples": len(samples),
  439. "best_val_accuracy": float(best_val_acc),
  440. "cross_validation_folds": fold_results,
  441. "mean_cv_accuracy": float(np.mean([f["accuracy"] for f in fold_results])),
  442. "epochs": epochs,
  443. "train_losses": all_train_losses,
  444. "val_losses": all_val_losses,
  445. "per_intent_metrics": {
  446. name: report_dict.get(name, {})
  447. for name in intent_names
  448. if name != "__negative__"
  449. },
  450. "created": time.strftime("%Y-%m-%d %H:%M:%S"),
  451. }
  452. with open(model_dir / "metafile.json", "w") as f:
  453. json.dump(metafile, f, indent=2, ensure_ascii=False)
  454. with open(model_dir / "intent_labels.json", "w") as f:
  455. json.dump(intent_names, f, ensure_ascii=False)
  456. # === 10. BIO Slot-Tagger trainieren ===
  457. if check_stop():
  458. return
  459. report(0.88, "Trainiere BIO Slot-Tagger...")
  460. try:
  461. self._train_bio_tagger(
  462. samples, encoder, model_dir, epochs,
  463. batch_size, report, check_stop, wait_if_paused,
  464. )
  465. except Exception as e:
  466. perror(f"BIO-Tagger Training fehlgeschlagen: {e}")
  467. import traceback
  468. pdebug(traceback.format_exc())
  469. # === 11. ONNX Export ===
  470. if self._settings.get("export_onnx", True):
  471. report(0.92, "Exportiere Classifier nach ONNX...")
  472. try:
  473. dummy_input = torch.randn(1, embedding_dim)
  474. torch.onnx.export(
  475. classifier,
  476. dummy_input,
  477. str(model_dir / "intent_classifier.onnx"),
  478. input_names=["embedding"],
  479. output_names=["intent_logits"],
  480. dynamic_axes={
  481. "embedding": {0: "batch"},
  482. "intent_logits": {0: "batch"},
  483. },
  484. opset_version=18,
  485. )
  486. pinfo(f"ONNX Classifier: {model_dir / 'intent_classifier.onnx'}")
  487. except Exception as e:
  488. perror(f"ONNX Classifier Export fehlgeschlagen: {e}")
  489. # Encoder ONNX Export
  490. report(0.95, "Exportiere Encoder nach ONNX...")
  491. try:
  492. self._export_encoder_onnx(encoder, model_dir)
  493. except Exception as e:
  494. perror(f"ONNX Encoder Export fehlgeschlagen: {e}")
  495. pinfo("Encoder muss zur Laufzeit via SentenceTransformer geladen werden")
  496. # Tokenizer speichern
  497. try:
  498. tokenizer = encoder.tokenizer
  499. tokenizer_dir = model_dir / "tokenizer"
  500. tokenizer.save_pretrained(str(tokenizer_dir))
  501. pinfo(f"Tokenizer gespeichert: {tokenizer_dir}")
  502. except Exception as e:
  503. pdebug(f"Tokenizer speichern fehlgeschlagen: {e}")
  504. report(1.0, f"Training abgeschlossen! Val-Acc: {best_val_acc:.3f}")
  505. pinfo(
  506. f"Intent-Classifier Training abgeschlossen: "
  507. f"{num_intents} Intents, Val-Acc={best_val_acc:.3f}, "
  508. f"CV-Mean={np.mean([f['accuracy'] for f in fold_results]):.3f}"
  509. )
  510. # === BIO Slot-Tagger ===
  511. def _train_bio_tagger(
  512. self,
  513. samples: list,
  514. encoder: Any,
  515. model_dir: Path,
  516. epochs: int,
  517. batch_size: int,
  518. report: Any,
  519. check_stop: Any,
  520. wait_if_paused: Any,
  521. ) -> None:
  522. """
  523. Trainiert den BIO-Slot-Tagger auf Token-Level Embeddings.
  524. Verwendet den gleichen Encoder wie der Intent-Classifier.
  525. Nur Samples mit Slots werden fuer das BIO-Training genutzt.
  526. """
  527. import torch
  528. import numpy as np
  529. from trixy_core.trainer.core.intent.bio_tagger import (
  530. BIOTagSet, generate_word_bio_tags, align_bio_to_tokens,
  531. )
  532. MAX_SEQ_LEN = 64
  533. # Tokenizer holen
  534. tokenizer = encoder.tokenizer
  535. # Samples mit Slots filtern
  536. slot_samples = [s for s in samples if s.slots and s.intent != "__negative__"]
  537. if not slot_samples:
  538. pinfo("BIO-Tagger: Keine Samples mit Slots — uebersprungen")
  539. return
  540. # Alle Slot-Namen sammeln
  541. all_slot_names: set[str] = set()
  542. for s in slot_samples:
  543. all_slot_names.update(s.slots.keys())
  544. # Auch Samples ohne Slots als Negative nehmen (lernt "O" Tags)
  545. no_slot_samples = [s for s in samples if not s.slots and s.intent != "__negative__"]
  546. import random
  547. rng = random.Random(42)
  548. neg_count = min(len(no_slot_samples), len(slot_samples))
  549. if no_slot_samples:
  550. neg_bio_samples = rng.sample(no_slot_samples, neg_count)
  551. else:
  552. neg_bio_samples = []
  553. bio_samples = slot_samples + neg_bio_samples
  554. rng.shuffle(bio_samples)
  555. pinfo(
  556. f"BIO-Tagger: {len(slot_samples)} Slot-Samples, "
  557. f"{len(neg_bio_samples)} Neg-Samples, "
  558. f"{len(all_slot_names)} Slot-Typen: {sorted(all_slot_names)}"
  559. )
  560. # BIO-TagSet erstellen
  561. tagset = BIOTagSet(slot_names=sorted(all_slot_names))
  562. pinfo(f"BIO-Tags: {tagset.num_tags} ({tagset.tags})")
  563. # Token-Embeddings und BIO-Labels berechnen
  564. report(0.89, f"BIO: Berechne Token-Embeddings ({len(bio_samples)} Samples)...")
  565. # Transformer-Modell aus SentenceTransformer extrahieren
  566. transformer = encoder[0]
  567. auto_model = transformer.auto_model.cpu() # Auf CPU (vermeidet CUDA-Mismatch)
  568. auto_model.eval()
  569. all_token_embs: list[np.ndarray] = []
  570. all_bio_labels: list[np.ndarray] = []
  571. for i, sample in enumerate(bio_samples):
  572. if check_stop():
  573. return
  574. # 1. Wort-Level BIO-Tags
  575. word_tags = generate_word_bio_tags(sample.text, sample.slots)
  576. # 2. Tokenisieren
  577. encoding = tokenizer(
  578. sample.text,
  579. padding="max_length",
  580. truncation=True,
  581. max_length=MAX_SEQ_LEN,
  582. return_tensors="pt",
  583. return_offsets_mapping=True,
  584. )
  585. # 3. Token-Level BIO-Tags (Subword-aligned)
  586. token_tags = align_bio_to_tokens(
  587. word_tags, sample.text, tokenizer, MAX_SEQ_LEN,
  588. )
  589. # 4. BIO-Label Indizes
  590. label_indices = [tagset.tag_to_idx(t) for t in token_tags]
  591. all_bio_labels.append(np.array(label_indices, dtype=np.int64))
  592. # 5. Token-Embeddings via Encoder
  593. with torch.no_grad():
  594. input_ids = encoding["input_ids"]
  595. attention_mask = encoding["attention_mask"]
  596. outputs = auto_model(input_ids=input_ids, attention_mask=attention_mask)
  597. token_emb = outputs.last_hidden_state[0].cpu().numpy() # (seq_len, 384)
  598. all_token_embs.append(token_emb)
  599. if (i + 1) % 500 == 0:
  600. report(0.89 + (i / len(bio_samples)) * 0.03,
  601. f"BIO: Token-Embeddings {i+1}/{len(bio_samples)}")
  602. # Zu Tensoren
  603. X_bio = torch.FloatTensor(np.stack(all_token_embs)) # (N, seq_len, 384)
  604. y_bio = torch.LongTensor(np.stack(all_bio_labels)) # (N, seq_len)
  605. embedding_dim = X_bio.shape[2]
  606. # BIO-Kopf erstellen
  607. bio_head = torch.nn.Sequential(
  608. torch.nn.Linear(embedding_dim, 128),
  609. torch.nn.ReLU(),
  610. torch.nn.Dropout(0.3),
  611. torch.nn.Linear(128, tagset.num_tags),
  612. )
  613. optimizer = torch.optim.Adam(bio_head.parameters(), lr=1e-3)
  614. loss_fn = torch.nn.CrossEntropyLoss(ignore_index=tagset.pad_idx)
  615. # Training
  616. bio_epochs = min(epochs, 20)
  617. report(0.92, f"BIO: Trainiere {bio_epochs} Epochen...")
  618. for epoch in range(bio_epochs):
  619. if check_stop():
  620. return
  621. wait_if_paused()
  622. bio_head.train()
  623. epoch_loss = 0.0
  624. indices = torch.randperm(len(X_bio))
  625. for i in range(0, len(indices), batch_size):
  626. batch_idx = indices[i:i + batch_size]
  627. batch_x = X_bio[batch_idx] # (B, seq_len, 384)
  628. batch_y = y_bio[batch_idx] # (B, seq_len)
  629. # Forward: (B, seq_len, 384) → (B, seq_len, num_tags)
  630. logits = bio_head(batch_x)
  631. # Loss: Reshape fuer CrossEntropy
  632. # (B*seq_len, num_tags) vs (B*seq_len,)
  633. loss = loss_fn(
  634. logits.view(-1, tagset.num_tags),
  635. batch_y.view(-1),
  636. )
  637. optimizer.zero_grad()
  638. loss.backward()
  639. optimizer.step()
  640. epoch_loss += loss.item()
  641. avg_loss = epoch_loss / max(1, len(X_bio) // batch_size)
  642. if (epoch + 1) % 5 == 0 or epoch == 0:
  643. # Accuracy (ohne PAD)
  644. bio_head.eval()
  645. with torch.no_grad():
  646. all_logits = bio_head(X_bio)
  647. preds = all_logits.argmax(dim=2) # (N, seq_len)
  648. mask = y_bio != tagset.pad_idx
  649. correct = ((preds == y_bio) & mask).sum().item()
  650. total = mask.sum().item()
  651. acc = correct / total if total > 0 else 0
  652. # Slot-spezifische Accuracy (nicht-O Tags)
  653. slot_mask = mask & (y_bio != tagset.o_idx)
  654. slot_correct = ((preds == y_bio) & slot_mask).sum().item()
  655. slot_total = slot_mask.sum().item()
  656. slot_acc = slot_correct / slot_total if slot_total > 0 else 0
  657. pinfo(
  658. f"BIO Epoch {epoch+1}/{bio_epochs}: "
  659. f"loss={avg_loss:.4f}, acc={acc:.3f}, slot_acc={slot_acc:.3f}"
  660. )
  661. # Speichern
  662. report(0.95, "BIO: Speichere Modell...")
  663. torch.save(bio_head.state_dict(), model_dir / "slot_tagger.pth")
  664. # BIO-Labels speichern
  665. with open(model_dir / "bio_labels.json", "w") as f:
  666. json.dump(tagset.to_dict(), f, indent=2, ensure_ascii=False)
  667. # ONNX Export des BIO-Kopfs
  668. try:
  669. bio_head.eval()
  670. dummy = torch.randn(1, MAX_SEQ_LEN, embedding_dim)
  671. torch.onnx.export(
  672. bio_head,
  673. dummy,
  674. str(model_dir / "slot_tagger.onnx"),
  675. input_names=["token_embeddings"],
  676. output_names=["bio_logits"],
  677. dynamic_axes={
  678. "token_embeddings": {0: "batch", 1: "seq_len"},
  679. "bio_logits": {0: "batch", 1: "seq_len"},
  680. },
  681. opset_version=18,
  682. )
  683. size_kb = (model_dir / "slot_tagger.onnx").stat().st_size / 1024
  684. pinfo(f"BIO ONNX: slot_tagger.onnx ({size_kb:.1f} KB)")
  685. except Exception as e:
  686. perror(f"BIO ONNX Export fehlgeschlagen: {e}")
  687. pinfo(
  688. f"BIO-Tagger Training abgeschlossen: "
  689. f"{tagset.num_tags} Tags, {len(bio_samples)} Samples"
  690. )
  691. # === Hilfsmethoden ===
  692. def _create_generator(self, slot_registry: SlotRegistry | None = None) -> DataGenerator:
  693. """Erstellt einen DataGenerator mit konfigurierter SlotRegistry."""
  694. if slot_registry is None:
  695. slot_registry = SlotRegistry()
  696. slot_file = self._settings.get("slot_lists_file", "")
  697. if slot_file and os.path.isfile(slot_file):
  698. slot_registry.load_from_file(slot_file)
  699. gen = DataGenerator(slot_registry)
  700. neg_file = self._dataset_config.get("negatives_file", "trainer/data/intent/negatives.txt")
  701. if neg_file:
  702. gen._negatives_file = neg_file
  703. return gen
  704. def _load_additional_examples(
  705. self, generator: DataGenerator, examples_dir: str,
  706. ) -> None:
  707. """
  708. Laedt zusaetzliche Beispiele aus .txt Dateien.
  709. Dateiformat:
  710. ```
  711. # intent: weather_forecast
  712. wie wird das wetter morgen
  713. wird es regnen
  714. ```
  715. """
  716. for txt_file in sorted(Path(examples_dir).glob("*.txt")):
  717. intent_name = ""
  718. examples = []
  719. with open(txt_file, encoding="utf-8") as f:
  720. for line in f:
  721. line = line.strip()
  722. if line.startswith("# intent:"):
  723. intent_name = line.split(":", 1)[1].strip()
  724. elif line and not line.startswith("#"):
  725. examples.append(line)
  726. if intent_name and examples:
  727. generator.add_intent(IntentDefinition(
  728. name=intent_name,
  729. examples=examples,
  730. ))
  731. pdebug(f"Beispiele: {intent_name} ({len(examples)} aus {txt_file.name})")
  732. def _export_encoder_onnx(
  733. self, encoder: Any, model_dir: Path,
  734. ) -> None:
  735. """
  736. Exportiert den SentenceTransformer-Encoder nach ONNX.
  737. Nutzt die optimum-Library fuer zuverlaessigen Export.
  738. Quantisiert auf Int8 (~112MB statt 448MB).
  739. Auf dem Pi braucht man dann kein PyTorch/sentence-transformers.
  740. """
  741. try:
  742. from optimum.onnxruntime import ORTModelForFeatureExtraction, ORTQuantizer
  743. from optimum.onnxruntime.configuration import AutoQuantizationConfig
  744. from transformers import AutoTokenizer
  745. except ImportError:
  746. perror("optimum nicht installiert — pip install optimum[onnxruntime]")
  747. return
  748. base_model = self._settings.get(
  749. "base_model",
  750. "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2",
  751. )
  752. encoder_dir = model_dir / "encoder_onnx"
  753. encoder_float_dir = model_dir / "encoder_float32"
  754. # 1. Float32 ONNX exportieren
  755. pinfo("ONNX Encoder: Exportiere float32...")
  756. model = ORTModelForFeatureExtraction.from_pretrained(base_model, export=True)
  757. model.save_pretrained(str(encoder_float_dir))
  758. # 2. Tokenizer speichern
  759. tokenizer = AutoTokenizer.from_pretrained(base_model)
  760. tokenizer.save_pretrained(str(encoder_float_dir))
  761. # 3. Quantisierung (Int8 — ~4x kleiner)
  762. pinfo("ONNX Encoder: Quantisiere auf Int8...")
  763. quantizer = ORTQuantizer.from_pretrained(str(encoder_float_dir))
  764. qconfig = AutoQuantizationConfig.avx2(is_static=False)
  765. quantizer.quantize(save_dir=str(encoder_dir), quantization_config=qconfig)
  766. # Tokenizer ins quantisierte Verzeichnis kopieren
  767. tokenizer.save_pretrained(str(encoder_dir))
  768. # Float32-Version loeschen (spart ~340MB)
  769. import shutil
  770. shutil.rmtree(str(encoder_float_dir), ignore_errors=True)
  771. # Groesse anzeigen
  772. total_size = sum(
  773. f.stat().st_size for f in encoder_dir.rglob("*") if f.is_file()
  774. )
  775. pinfo(f"ONNX Encoder: {encoder_dir} ({total_size / 1024 / 1024:.1f} MB, Int8)")