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.
- {shadow_tools-0.4.2 → shadow_tools-0.5.1}/PKG-INFO +19 -1
- {shadow_tools-0.4.2 → shadow_tools-0.5.1}/README.md +18 -0
- {shadow_tools-0.4.2 → shadow_tools-0.5.1}/pyproject.toml +1 -1
- {shadow_tools-0.4.2 → shadow_tools-0.5.1}/src/shadowtools/__init__.py +3 -3
- {shadow_tools-0.4.2 → shadow_tools-0.5.1}/src/shadowtools/training.py +148 -2
- {shadow_tools-0.4.2 → shadow_tools-0.5.1}/.gitignore +0 -0
- {shadow_tools-0.4.2 → shadow_tools-0.5.1}/LICENSE +0 -0
- {shadow_tools-0.4.2 → shadow_tools-0.5.1}/src/shadowtools/ai.py +0 -0
- {shadow_tools-0.4.2 → shadow_tools-0.5.1}/src/shadowtools/async_tools.py +0 -0
- {shadow_tools-0.4.2 → shadow_tools-0.5.1}/src/shadowtools/cli.py +0 -0
- {shadow_tools-0.4.2 → shadow_tools-0.5.1}/src/shadowtools/config.py +0 -0
- {shadow_tools-0.4.2 → shadow_tools-0.5.1}/src/shadowtools/data.py +0 -0
- {shadow_tools-0.4.2 → shadow_tools-0.5.1}/src/shadowtools/files.py +0 -0
- {shadow_tools-0.4.2 → shadow_tools-0.5.1}/src/shadowtools/hashing.py +0 -0
- {shadow_tools-0.4.2 → shadow_tools-0.5.1}/src/shadowtools/http.py +0 -0
- {shadow_tools-0.4.2 → shadow_tools-0.5.1}/src/shadowtools/ids.py +0 -0
- {shadow_tools-0.4.2 → shadow_tools-0.5.1}/src/shadowtools/network.py +0 -0
- {shadow_tools-0.4.2 → shadow_tools-0.5.1}/src/shadowtools/retry.py +0 -0
- {shadow_tools-0.4.2 → shadow_tools-0.5.1}/src/shadowtools/security.py +0 -0
- {shadow_tools-0.4.2 → shadow_tools-0.5.1}/src/shadowtools/text.py +0 -0
- {shadow_tools-0.4.2 → shadow_tools-0.5.1}/src/shadowtools/validation.py +0 -0
- {shadow_tools-0.4.2 → shadow_tools-0.5.1}/src/shadowtools/websocket.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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()
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|