shadow-tools 0.4.2__tar.gz → 0.5.1__tar.gz

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 (23) hide show
  1. {shadow_tools-0.4.2 → shadow_tools-0.5.1}/PKG-INFO +19 -1
  2. {shadow_tools-0.4.2 → shadow_tools-0.5.1}/README.md +18 -0
  3. {shadow_tools-0.4.2 → shadow_tools-0.5.1}/pyproject.toml +1 -1
  4. {shadow_tools-0.4.2 → shadow_tools-0.5.1}/src/shadowtools/__init__.py +3 -3
  5. {shadow_tools-0.4.2 → shadow_tools-0.5.1}/src/shadowtools/training.py +148 -2
  6. {shadow_tools-0.4.2 → shadow_tools-0.5.1}/.gitignore +0 -0
  7. {shadow_tools-0.4.2 → shadow_tools-0.5.1}/LICENSE +0 -0
  8. {shadow_tools-0.4.2 → shadow_tools-0.5.1}/src/shadowtools/ai.py +0 -0
  9. {shadow_tools-0.4.2 → shadow_tools-0.5.1}/src/shadowtools/async_tools.py +0 -0
  10. {shadow_tools-0.4.2 → shadow_tools-0.5.1}/src/shadowtools/cli.py +0 -0
  11. {shadow_tools-0.4.2 → shadow_tools-0.5.1}/src/shadowtools/config.py +0 -0
  12. {shadow_tools-0.4.2 → shadow_tools-0.5.1}/src/shadowtools/data.py +0 -0
  13. {shadow_tools-0.4.2 → shadow_tools-0.5.1}/src/shadowtools/files.py +0 -0
  14. {shadow_tools-0.4.2 → shadow_tools-0.5.1}/src/shadowtools/hashing.py +0 -0
  15. {shadow_tools-0.4.2 → shadow_tools-0.5.1}/src/shadowtools/http.py +0 -0
  16. {shadow_tools-0.4.2 → shadow_tools-0.5.1}/src/shadowtools/ids.py +0 -0
  17. {shadow_tools-0.4.2 → shadow_tools-0.5.1}/src/shadowtools/network.py +0 -0
  18. {shadow_tools-0.4.2 → shadow_tools-0.5.1}/src/shadowtools/retry.py +0 -0
  19. {shadow_tools-0.4.2 → shadow_tools-0.5.1}/src/shadowtools/security.py +0 -0
  20. {shadow_tools-0.4.2 → shadow_tools-0.5.1}/src/shadowtools/text.py +0 -0
  21. {shadow_tools-0.4.2 → shadow_tools-0.5.1}/src/shadowtools/validation.py +0 -0
  22. {shadow_tools-0.4.2 → shadow_tools-0.5.1}/src/shadowtools/websocket.py +0 -0
  23. {shadow_tools-0.4.2 → shadow_tools-0.5.1}/tests/test_core.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: shadow-tools
3
- Version: 0.4.2
3
+ Version: 0.5.1
4
4
  Summary: A practical all-in-one Python toolkit for HTTP, WebSockets, files, data, validation, async work, and CLIs.
5
5
  Project-URL: Homepage, https://github.com/WilliamLaurento/shadow-tools
6
6
  Project-URL: Issues, https://github.com/WilliamLaurento/shadow-tools/issues
@@ -26,6 +26,24 @@ Description-Content-Type: text/markdown
26
26
 
27
27
  # Shadow Tools
28
28
 
29
+ ## Train on regular text
30
+
31
+ ```python
32
+ from shadowtools import predict_text, train_text
33
+
34
+ texts = ["I love this game", "this is terrible", "really fun game", "I hate it"]
35
+ labels = ["positive", "negative", "positive", "negative"]
36
+
37
+ model, history = train_text(texts, labels, epochs=20)
38
+ print(predict_text(model, ["this game is fun", "I really hate this"]))
39
+ # ['positive', 'negative']
40
+ ```
41
+
42
+ `train_text` learns a vocabulary from your examples. Its tokenizer keeps words,
43
+ contractions, URLs, punctuation, and common two-word phrases, then converts each
44
+ text into a TF-IDF number vector before training. Use `SmartTokenizer` yourself
45
+ when you want to fit once and reuse the exact same vocabulary later.
46
+
29
47
  `shadow-tools` is a lightweight Python toolbox for everyday apps and scripts. It keeps the basics in one clean package without trying to replace specialized libraries.
30
48
 
31
49
  ## Install
@@ -1,5 +1,23 @@
1
1
  # Shadow Tools
2
2
 
3
+ ## Train on regular text
4
+
5
+ ```python
6
+ from shadowtools import predict_text, train_text
7
+
8
+ texts = ["I love this game", "this is terrible", "really fun game", "I hate it"]
9
+ labels = ["positive", "negative", "positive", "negative"]
10
+
11
+ model, history = train_text(texts, labels, epochs=20)
12
+ print(predict_text(model, ["this game is fun", "I really hate this"]))
13
+ # ['positive', 'negative']
14
+ ```
15
+
16
+ `train_text` learns a vocabulary from your examples. Its tokenizer keeps words,
17
+ contractions, URLs, punctuation, and common two-word phrases, then converts each
18
+ text into a TF-IDF number vector before training. Use `SmartTokenizer` yourself
19
+ when you want to fit once and reuse the exact same vocabulary later.
20
+
3
21
  `shadow-tools` is a lightweight Python toolbox for everyday apps and scripts. It keeps the basics in one clean package without trying to replace specialized libraries.
4
22
 
5
23
  ## Install
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "shadow-tools"
7
- version = "0.4.2"
7
+ version = "0.5.1"
8
8
  description = "A practical all-in-one Python toolkit for HTTP, WebSockets, files, data, validation, async work, and CLIs."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -10,13 +10,13 @@ from .network import generate_random_ips, generate_test_ips, is_subdomain_of, ma
10
10
  from .retry import retry
11
11
  from .security import audit_headers, make_password, redact_secrets, verify_file_hash
12
12
  from .text import chunks, slugify, to_camel, to_snake
13
- from .training import fine_tune, make_loaders, train
13
+ from .training import SmartTokenizer, fine_tune, make_loaders, predict_text, train, train_text
14
14
  from .validation import is_email, is_url, require_keys
15
15
 
16
- __version__ = "0.4.2"
16
+ __version__ = "0.5.1"
17
17
 
18
18
  __all__ = [
19
19
  "append_text", "audit_headers", "chunks", "ensure_dir", "env", "file_hash", "generate_random_ips", "generate_test_ips", "get", "hash_text", "is_email", "is_subdomain_of", "is_url", "make_password", "make_subdomain", "random_token", "read_csv",
20
- "read_json", "read_text", "request", "require_env", "require_keys", "retry", "reverse_dns", "resolve_dns", "slugify", "timestamp_id", "to_bool", "to_camel", "to_snake", "train", "fine_tune", "make_loaders", "uuid4", "write_csv",
20
+ "read_json", "read_text", "request", "require_env", "require_keys", "retry", "reverse_dns", "resolve_dns", "slugify", "timestamp_id", "to_bool", "to_camel", "to_snake", "train", "fine_tune", "make_loaders", "SmartTokenizer", "train_text", "predict_text", "uuid4", "write_csv",
21
21
  "write_json", "write_text", "redact_secrets", "verify_file_hash",
22
22
  ]
@@ -2,13 +2,108 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import logging
5
+ import math
6
+ import re
5
7
  from dataclasses import dataclass, field
6
8
  from pathlib import Path
7
- from typing import Any, Callable, Iterable, Literal, Mapping
9
+ from typing import Any, Callable, Iterable, Literal, Mapping, Sequence
8
10
 
9
11
  logger = logging.getLogger(__name__)
10
12
 
11
13
 
14
+ class SmartTokenizer:
15
+ """A compact tokenizer that learns words and useful word-pairs from your text."""
16
+
17
+ PAD = "[PAD]"
18
+ UNK = "[UNK]"
19
+ _pattern = re.compile(r"https?://\S+|[\w]+(?:['’-][\w]+)*|[^\w\s]", re.UNICODE)
20
+
21
+ def __init__(self, *, min_frequency: int = 1, max_vocab: int = 10_000,
22
+ ngram_range: tuple[int, int] = (1, 2), lowercase: bool = True) -> None:
23
+ if min_frequency < 1 or max_vocab < 3:
24
+ raise ValueError("min_frequency must be at least 1 and max_vocab at least 3.")
25
+ if not 1 <= ngram_range[0] <= ngram_range[1] <= 3:
26
+ raise ValueError("ngram_range must be ordered between 1 and 3.")
27
+ self.min_frequency = min_frequency
28
+ self.max_vocab = max_vocab
29
+ self.ngram_range = ngram_range
30
+ self.lowercase = lowercase
31
+ self.vocab: dict[str, int] = {self.PAD: 0, self.UNK: 1}
32
+ self.idf: list[float] = [0.0, 0.0]
33
+ self._fitted = False
34
+
35
+ def tokenize(self, text: str) -> list[str]:
36
+ if not isinstance(text, str):
37
+ raise TypeError("text must be a string.")
38
+ cleaned = text.strip().lower() if self.lowercase else text.strip()
39
+ words = self._pattern.findall(cleaned)
40
+ terms: list[str] = []
41
+ low, high = self.ngram_range
42
+ for size in range(low, high + 1):
43
+ terms.extend(" ".join(words[index:index + size]) for index in range(len(words) - size + 1))
44
+ return terms
45
+
46
+ def fit(self, texts: Sequence[str]) -> "SmartTokenizer":
47
+ if not texts:
48
+ raise ValueError("texts must contain at least one item.")
49
+ counts: dict[str, int] = {}
50
+ documents: dict[str, int] = {}
51
+ for text in texts:
52
+ terms = self.tokenize(text)
53
+ for term in terms:
54
+ counts[term] = counts.get(term, 0) + 1
55
+ for term in set(terms):
56
+ documents[term] = documents.get(term, 0) + 1
57
+ kept = [term for term, count in counts.items() if count >= self.min_frequency]
58
+ kept.sort(key=lambda term: (-counts[term], term))
59
+ kept = kept[: self.max_vocab - 2]
60
+ self.vocab = {self.PAD: 0, self.UNK: 1, **{term: index + 2 for index, term in enumerate(kept)}}
61
+ text_count = len(texts)
62
+ self.idf = [0.0, 0.0] + [math.log((1 + text_count) / (1 + documents[term])) + 1 for term in kept]
63
+ self._fitted = True
64
+ return self
65
+
66
+ def encode(self, text: str) -> list[int]:
67
+ self._check_fitted()
68
+ return [self.vocab.get(term, 1) for term in self.tokenize(text)]
69
+
70
+ def vectorize(self, texts: Sequence[str]) -> list[list[float]]:
71
+ """Return fixed-length TF-IDF vectors suitable for ``train``."""
72
+ self._check_fitted()
73
+ vectors: list[list[float]] = []
74
+ for text in texts:
75
+ vector = [0.0] * len(self.vocab)
76
+ counts: dict[int, int] = {}
77
+ for token_id in self.encode(text):
78
+ if token_id > 1:
79
+ counts[token_id] = counts.get(token_id, 0) + 1
80
+ total = sum(counts.values())
81
+ if total:
82
+ for token_id, count in counts.items():
83
+ vector[token_id] = (count / total) * self.idf[token_id]
84
+ vectors.append(vector)
85
+ return vectors
86
+
87
+ def to_dict(self) -> dict[str, Any]:
88
+ self._check_fitted()
89
+ return {"min_frequency": self.min_frequency, "max_vocab": self.max_vocab,
90
+ "ngram_range": self.ngram_range, "lowercase": self.lowercase,
91
+ "vocab": self.vocab, "idf": self.idf}
92
+
93
+ @classmethod
94
+ def from_dict(cls, data: Mapping[str, Any]) -> "SmartTokenizer":
95
+ tokenizer = cls(min_frequency=int(data["min_frequency"]), max_vocab=int(data["max_vocab"]),
96
+ ngram_range=tuple(data["ngram_range"]), lowercase=bool(data["lowercase"]))
97
+ tokenizer.vocab = {str(term): int(token_id) for term, token_id in data["vocab"].items()}
98
+ tokenizer.idf = [float(value) for value in data["idf"]]
99
+ tokenizer._fitted = True
100
+ return tokenizer
101
+
102
+ def _check_fitted(self) -> None:
103
+ if not self._fitted:
104
+ raise RuntimeError("Fit the tokenizer first: SmartTokenizer().fit(texts).")
105
+
106
+
12
107
  @dataclass
13
108
  class TrainingConfig:
14
109
  mode: Literal["from_scratch", "fine_tune"]
@@ -36,9 +131,10 @@ class Trainer:
36
131
  self.config = config if isinstance(config, TrainingConfig) else TrainingConfig(**dict(config))
37
132
  self.torch = self._torch()
38
133
  self.device = self._device()
39
- self.model = self._build_model().to(self.device)
134
+ self.model = self._build_model()
40
135
  self._freeze()
41
136
  self._replace_head()
137
+ self.model = self.model.to(self.device)
42
138
 
43
139
  @staticmethod
44
140
  def _torch() -> Any:
@@ -228,3 +324,53 @@ def fine_tune(model: Any, features: Any, labels: Any, *, epochs: int = 3,
228
324
  freeze_layers=freeze_layers, head_attr=head_attr, num_classes=num_classes,
229
325
  )
230
326
  return run_training(config, train_loader, validation_loader)
327
+
328
+
329
+ def train_text(texts: Sequence[str], labels: Sequence[int | str], *, tokenizer: SmartTokenizer | None = None,
330
+ min_frequency: int = 1, max_vocab: int = 10_000,
331
+ ngram_range: tuple[int, int] = (1, 2), hidden_sizes: list[int] | None = None,
332
+ epochs: int = 8, learning_rate: float = 1e-3, batch_size: int = 32,
333
+ validation_split: float = 0.2, device: str = "auto") -> tuple[Trainer, list[dict[str, float]]]:
334
+ """Train directly on text and integer labels with a learned word/phrase tokenizer."""
335
+ if len(texts) != len(labels) or len(texts) < 2:
336
+ raise ValueError("texts and labels must have the same length of at least 2.")
337
+ if not all(isinstance(label, (int, str)) and not isinstance(label, bool) for label in labels):
338
+ raise TypeError("labels must be all integers or all text labels.")
339
+ text_labels = all(isinstance(label, str) for label in labels)
340
+ number_labels = all(isinstance(label, int) and not isinstance(label, bool) for label in labels)
341
+ if not (text_labels or number_labels):
342
+ raise TypeError("labels must be all integers or all text labels; do not mix them.")
343
+ class_names: list[int | str] | None = None
344
+ encoded_labels: Sequence[int]
345
+ if text_labels:
346
+ class_names = list(dict.fromkeys(labels))
347
+ if len(class_names) < 2:
348
+ raise ValueError("text labels need at least two different classes.")
349
+ class_ids = {label: index for index, label in enumerate(class_names)}
350
+ encoded_labels = [class_ids[label] for label in labels]
351
+ else:
352
+ encoded_labels = labels # type: ignore[assignment]
353
+ learned = tokenizer or SmartTokenizer(min_frequency=min_frequency, max_vocab=max_vocab, ngram_range=ngram_range)
354
+ if not learned._fitted:
355
+ learned.fit(texts)
356
+ trainer, history = train(learned.vectorize(texts), encoded_labels, hidden_sizes=hidden_sizes,
357
+ epochs=epochs, learning_rate=learning_rate, batch_size=batch_size,
358
+ validation_split=validation_split, device=device)
359
+ trainer.tokenizer = learned
360
+ trainer.class_names = class_names
361
+ return trainer, history
362
+
363
+
364
+ def predict_text(trainer: Trainer, texts: str | Sequence[str]) -> list[int | str]:
365
+ """Classify one string or a list of strings returned by ``train_text``."""
366
+ if not hasattr(trainer, "tokenizer"):
367
+ raise ValueError("trainer has no tokenizer; create it with train_text().")
368
+ values = [texts] if isinstance(texts, str) else list(texts)
369
+ vectors = trainer.tokenizer.vectorize(values)
370
+ torch = Trainer._torch()
371
+ trainer.model.eval()
372
+ with torch.no_grad():
373
+ tensor = torch.as_tensor(vectors, dtype=torch.float32).to(trainer.device)
374
+ predictions = trainer.model(tensor).argmax(dim=1).cpu().tolist()
375
+ class_names = getattr(trainer, "class_names", None)
376
+ return [class_names[prediction] for prediction in predictions] if class_names is not None else predictions
File without changes
File without changes