shadow-tools 0.4.2__tar.gz → 0.5.0__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.0}/PKG-INFO +18 -1
  2. {shadow_tools-0.4.2 → shadow_tools-0.5.0}/README.md +17 -0
  3. {shadow_tools-0.4.2 → shadow_tools-0.5.0}/pyproject.toml +1 -1
  4. {shadow_tools-0.4.2 → shadow_tools-0.5.0}/src/shadowtools/__init__.py +3 -3
  5. {shadow_tools-0.4.2 → shadow_tools-0.5.0}/src/shadowtools/training.py +129 -2
  6. {shadow_tools-0.4.2 → shadow_tools-0.5.0}/.gitignore +0 -0
  7. {shadow_tools-0.4.2 → shadow_tools-0.5.0}/LICENSE +0 -0
  8. {shadow_tools-0.4.2 → shadow_tools-0.5.0}/src/shadowtools/ai.py +0 -0
  9. {shadow_tools-0.4.2 → shadow_tools-0.5.0}/src/shadowtools/async_tools.py +0 -0
  10. {shadow_tools-0.4.2 → shadow_tools-0.5.0}/src/shadowtools/cli.py +0 -0
  11. {shadow_tools-0.4.2 → shadow_tools-0.5.0}/src/shadowtools/config.py +0 -0
  12. {shadow_tools-0.4.2 → shadow_tools-0.5.0}/src/shadowtools/data.py +0 -0
  13. {shadow_tools-0.4.2 → shadow_tools-0.5.0}/src/shadowtools/files.py +0 -0
  14. {shadow_tools-0.4.2 → shadow_tools-0.5.0}/src/shadowtools/hashing.py +0 -0
  15. {shadow_tools-0.4.2 → shadow_tools-0.5.0}/src/shadowtools/http.py +0 -0
  16. {shadow_tools-0.4.2 → shadow_tools-0.5.0}/src/shadowtools/ids.py +0 -0
  17. {shadow_tools-0.4.2 → shadow_tools-0.5.0}/src/shadowtools/network.py +0 -0
  18. {shadow_tools-0.4.2 → shadow_tools-0.5.0}/src/shadowtools/retry.py +0 -0
  19. {shadow_tools-0.4.2 → shadow_tools-0.5.0}/src/shadowtools/security.py +0 -0
  20. {shadow_tools-0.4.2 → shadow_tools-0.5.0}/src/shadowtools/text.py +0 -0
  21. {shadow_tools-0.4.2 → shadow_tools-0.5.0}/src/shadowtools/validation.py +0 -0
  22. {shadow_tools-0.4.2 → shadow_tools-0.5.0}/src/shadowtools/websocket.py +0 -0
  23. {shadow_tools-0.4.2 → shadow_tools-0.5.0}/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.0
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,23 @@ 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 = [1, 0, 1, 0] # 1 = positive, 0 = 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
+ ```
40
+
41
+ `train_text` learns a vocabulary from your examples. Its tokenizer keeps words,
42
+ contractions, URLs, punctuation, and common two-word phrases, then converts each
43
+ text into a TF-IDF number vector before training. Use `SmartTokenizer` yourself
44
+ when you want to fit once and reuse the exact same vocabulary later.
45
+
29
46
  `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
47
 
31
48
  ## Install
@@ -1,5 +1,22 @@
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 = [1, 0, 1, 0] # 1 = positive, 0 = 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
+ ```
14
+
15
+ `train_text` learns a vocabulary from your examples. Its tokenizer keeps words,
16
+ contractions, URLs, punctuation, and common two-word phrases, then converts each
17
+ text into a TF-IDF number vector before training. Use `SmartTokenizer` yourself
18
+ when you want to fit once and reuse the exact same vocabulary later.
19
+
3
20
  `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
21
 
5
22
  ## 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.0"
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.0"
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,34 @@ 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], *, 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
+ learned = tokenizer or SmartTokenizer(min_frequency=min_frequency, max_vocab=max_vocab, ngram_range=ngram_range)
338
+ if not learned._fitted:
339
+ learned.fit(texts)
340
+ trainer, history = train(learned.vectorize(texts), labels, hidden_sizes=hidden_sizes,
341
+ epochs=epochs, learning_rate=learning_rate, batch_size=batch_size,
342
+ validation_split=validation_split, device=device)
343
+ trainer.tokenizer = learned
344
+ return trainer, history
345
+
346
+
347
+ def predict_text(trainer: Trainer, texts: str | Sequence[str]) -> list[int]:
348
+ """Classify one string or a list of strings returned by ``train_text``."""
349
+ if not hasattr(trainer, "tokenizer"):
350
+ raise ValueError("trainer has no tokenizer; create it with train_text().")
351
+ values = [texts] if isinstance(texts, str) else list(texts)
352
+ vectors = trainer.tokenizer.vectorize(values)
353
+ torch = Trainer._torch()
354
+ trainer.model.eval()
355
+ with torch.no_grad():
356
+ tensor = torch.as_tensor(vectors, dtype=torch.float32).to(trainer.device)
357
+ return trainer.model(tensor).argmax(dim=1).cpu().tolist()
File without changes
File without changes