shadow-tools 0.7.2__tar.gz → 0.8.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.7.2 → shadow_tools-0.8.1}/PKG-INFO +29 -1
- {shadow_tools-0.7.2 → shadow_tools-0.8.1}/README.md +28 -0
- {shadow_tools-0.7.2 → shadow_tools-0.8.1}/pyproject.toml +1 -1
- {shadow_tools-0.7.2 → shadow_tools-0.8.1}/src/shadowtools/__init__.py +4 -4
- {shadow_tools-0.7.2 → shadow_tools-0.8.1}/src/shadowtools/generator.py +51 -4
- shadow_tools-0.8.1/tests/test_generator.py +43 -0
- {shadow_tools-0.7.2 → shadow_tools-0.8.1}/.gitignore +0 -0
- {shadow_tools-0.7.2 → shadow_tools-0.8.1}/LICENSE +0 -0
- {shadow_tools-0.7.2 → shadow_tools-0.8.1}/src/shadowtools/ai.py +0 -0
- {shadow_tools-0.7.2 → shadow_tools-0.8.1}/src/shadowtools/async_tools.py +0 -0
- {shadow_tools-0.7.2 → shadow_tools-0.8.1}/src/shadowtools/bpe.py +0 -0
- {shadow_tools-0.7.2 → shadow_tools-0.8.1}/src/shadowtools/cli.py +0 -0
- {shadow_tools-0.7.2 → shadow_tools-0.8.1}/src/shadowtools/config.py +0 -0
- {shadow_tools-0.7.2 → shadow_tools-0.8.1}/src/shadowtools/data.py +0 -0
- {shadow_tools-0.7.2 → shadow_tools-0.8.1}/src/shadowtools/files.py +0 -0
- {shadow_tools-0.7.2 → shadow_tools-0.8.1}/src/shadowtools/hashing.py +0 -0
- {shadow_tools-0.7.2 → shadow_tools-0.8.1}/src/shadowtools/http.py +0 -0
- {shadow_tools-0.7.2 → shadow_tools-0.8.1}/src/shadowtools/ids.py +0 -0
- {shadow_tools-0.7.2 → shadow_tools-0.8.1}/src/shadowtools/network.py +0 -0
- {shadow_tools-0.7.2 → shadow_tools-0.8.1}/src/shadowtools/retry.py +0 -0
- {shadow_tools-0.7.2 → shadow_tools-0.8.1}/src/shadowtools/security.py +0 -0
- {shadow_tools-0.7.2 → shadow_tools-0.8.1}/src/shadowtools/text.py +0 -0
- {shadow_tools-0.7.2 → shadow_tools-0.8.1}/src/shadowtools/training.py +0 -0
- {shadow_tools-0.7.2 → shadow_tools-0.8.1}/src/shadowtools/validation.py +0 -0
- {shadow_tools-0.7.2 → shadow_tools-0.8.1}/src/shadowtools/websocket.py +0 -0
- {shadow_tools-0.7.2 → shadow_tools-0.8.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.8.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
|
|
@@ -28,6 +28,18 @@ Description-Content-Type: text/markdown
|
|
|
28
28
|
|
|
29
29
|
# Shadow Tools
|
|
30
30
|
|
|
31
|
+
## 0.8.1 generation fix
|
|
32
|
+
|
|
33
|
+
`ask(bot, question)` returns only the generated answer and stops before a new
|
|
34
|
+
`Question:` or `Answer:` line. `teach()` now trains with an end token after each
|
|
35
|
+
answer. Retrain existing bots to learn these boundaries. `say()` returns only
|
|
36
|
+
new text by default; use `include_prompt=True` to include the input.
|
|
37
|
+
|
|
38
|
+
Generation accepts `repetition_penalty=1.1` (1.0 disables it). This discourages
|
|
39
|
+
repeated tokens; it cannot guarantee original or accurate answers. Training
|
|
40
|
+
on a few examples can still memorize those examples. No pretrained weights
|
|
41
|
+
or answer lookup are used.
|
|
42
|
+
|
|
31
43
|
## Train on regular text
|
|
32
44
|
|
|
33
45
|
```python
|
|
@@ -76,6 +88,22 @@ bot.save("my_text_model")
|
|
|
76
88
|
Training prints the tokenizer status plus each epoch and loss. Use
|
|
77
89
|
`learn("corpus.txt", epochs=20, verbose=False)` when you do not want updates.
|
|
78
90
|
|
|
91
|
+
## Train a question-and-answer bot
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from shadowtools import ask, teach
|
|
95
|
+
|
|
96
|
+
bot = teach({
|
|
97
|
+
"What is ChatGPT?": "ChatGPT is an AI system that predicts useful next tokens to answer a prompt.",
|
|
98
|
+
"How does a tokenizer work?": "A tokenizer splits text into smaller tokens a model can process.",
|
|
99
|
+
"What is machine learning?": "Machine learning is when a model learns patterns from examples.",
|
|
100
|
+
}, epochs=20)
|
|
101
|
+
|
|
102
|
+
print(ask(bot, "What is ChatGPT?"))
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Add many more real Q&A pairs for better answers.
|
|
106
|
+
|
|
79
107
|
Install it with `pip install "shadow-tools[ml,nlp]"`. The package trains the
|
|
80
108
|
tokenizer and Transformer together, then converts predicted token IDs back to
|
|
81
109
|
normal text for you.
|
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Shadow Tools
|
|
2
2
|
|
|
3
|
+
## 0.8.1 generation fix
|
|
4
|
+
|
|
5
|
+
`ask(bot, question)` returns only the generated answer and stops before a new
|
|
6
|
+
`Question:` or `Answer:` line. `teach()` now trains with an end token after each
|
|
7
|
+
answer. Retrain existing bots to learn these boundaries. `say()` returns only
|
|
8
|
+
new text by default; use `include_prompt=True` to include the input.
|
|
9
|
+
|
|
10
|
+
Generation accepts `repetition_penalty=1.1` (1.0 disables it). This discourages
|
|
11
|
+
repeated tokens; it cannot guarantee original or accurate answers. Training
|
|
12
|
+
on a few examples can still memorize those examples. No pretrained weights
|
|
13
|
+
or answer lookup are used.
|
|
14
|
+
|
|
3
15
|
## Train on regular text
|
|
4
16
|
|
|
5
17
|
```python
|
|
@@ -48,6 +60,22 @@ bot.save("my_text_model")
|
|
|
48
60
|
Training prints the tokenizer status plus each epoch and loss. Use
|
|
49
61
|
`learn("corpus.txt", epochs=20, verbose=False)` when you do not want updates.
|
|
50
62
|
|
|
63
|
+
## Train a question-and-answer bot
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from shadowtools import ask, teach
|
|
67
|
+
|
|
68
|
+
bot = teach({
|
|
69
|
+
"What is ChatGPT?": "ChatGPT is an AI system that predicts useful next tokens to answer a prompt.",
|
|
70
|
+
"How does a tokenizer work?": "A tokenizer splits text into smaller tokens a model can process.",
|
|
71
|
+
"What is machine learning?": "Machine learning is when a model learns patterns from examples.",
|
|
72
|
+
}, epochs=20)
|
|
73
|
+
|
|
74
|
+
print(ask(bot, "What is ChatGPT?"))
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Add many more real Q&A pairs for better answers.
|
|
78
|
+
|
|
51
79
|
Install it with `pip install "shadow-tools[ml,nlp]"`. The package trains the
|
|
52
80
|
tokenizer and Transformer together, then converts predicted token IDs back to
|
|
53
81
|
normal text for you.
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "shadow-tools"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.8.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"
|
|
@@ -5,7 +5,7 @@ from .bpe import BPETokenizer
|
|
|
5
5
|
from .config import env, require_env, to_bool
|
|
6
6
|
from .files import append_text, ensure_dir, read_text, write_text
|
|
7
7
|
from .hashing import file_hash, hash_text
|
|
8
|
-
from .generator import GeneratorConfig, TextGenerator, generate_text, learn, say, train_generator
|
|
8
|
+
from .generator import GeneratorConfig, TextGenerator, ask, generate_text, learn, say, teach, train_generator
|
|
9
9
|
from .http import get, request
|
|
10
10
|
from .ids import random_token, timestamp_id, uuid4
|
|
11
11
|
from .network import generate_random_ips, generate_test_ips, is_subdomain_of, make_subdomain, resolve_dns, reverse_dns
|
|
@@ -15,10 +15,10 @@ from .text import chunks, slugify, to_camel, to_snake
|
|
|
15
15
|
from .training import SmartTokenizer, fine_tune, make_loaders, predict_text, train, train_text
|
|
16
16
|
from .validation import is_email, is_url, require_keys
|
|
17
17
|
|
|
18
|
-
__version__ = "0.
|
|
18
|
+
__version__ = "0.8.1"
|
|
19
19
|
|
|
20
20
|
__all__ = [
|
|
21
|
-
"append_text", "audit_headers", "BPETokenizer", "chunks", "ensure_dir", "env", "file_hash", "GeneratorConfig", "generate_random_ips", "generate_test_ips", "generate_text", "get", "hash_text", "is_email", "is_subdomain_of", "is_url", "learn", "make_password", "make_subdomain", "random_token", "read_csv",
|
|
22
|
-
"read_json", "read_text", "request", "require_env", "require_keys", "retry", "reverse_dns", "resolve_dns", "say", "slugify", "timestamp_id", "to_bool", "to_camel", "to_snake", "TextGenerator", "train", "train_generator", "fine_tune", "make_loaders", "SmartTokenizer", "train_text", "predict_text", "uuid4", "write_csv",
|
|
21
|
+
"append_text", "ask", "audit_headers", "BPETokenizer", "chunks", "ensure_dir", "env", "file_hash", "GeneratorConfig", "generate_random_ips", "generate_test_ips", "generate_text", "get", "hash_text", "is_email", "is_subdomain_of", "is_url", "learn", "make_password", "make_subdomain", "random_token", "read_csv",
|
|
22
|
+
"read_json", "read_text", "request", "require_env", "require_keys", "retry", "reverse_dns", "resolve_dns", "say", "slugify", "teach", "timestamp_id", "to_bool", "to_camel", "to_snake", "TextGenerator", "train", "train_generator", "fine_tune", "make_loaders", "SmartTokenizer", "train_text", "predict_text", "uuid4", "write_csv",
|
|
23
23
|
"write_json", "write_text", "redact_secrets", "verify_file_hash",
|
|
24
24
|
]
|
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
|
|
4
4
|
from dataclasses import asdict, dataclass
|
|
5
5
|
from pathlib import Path
|
|
6
|
-
from typing import Any, Sequence
|
|
6
|
+
from typing import Any, Mapping, Sequence
|
|
7
7
|
|
|
8
8
|
from .bpe import BPETokenizer
|
|
9
9
|
|
|
@@ -91,7 +91,12 @@ class TextGenerator:
|
|
|
91
91
|
if show_progress:
|
|
92
92
|
print("Building tokenizer...")
|
|
93
93
|
self.tokenizer = BPETokenizer(vocab_size=self.config.vocab_size, min_frequency=1).train([corpus])
|
|
94
|
-
|
|
94
|
+
# Preserve document boundaries so the model learns when to stop.
|
|
95
|
+
documents = [texts] if isinstance(texts, str) else list(texts)
|
|
96
|
+
token_ids = []
|
|
97
|
+
for document in documents:
|
|
98
|
+
token_ids.extend(self.tokenizer.encode(document, add_special_tokens=False)["input_ids"])
|
|
99
|
+
token_ids.append(self.tokenizer.eos_id)
|
|
95
100
|
if len(token_ids) <= self.config.block_size:
|
|
96
101
|
raise ValueError("Training text needs more tokens than block_size.")
|
|
97
102
|
self.model = self._build_model(self.tokenizer.size)
|
|
@@ -133,14 +138,22 @@ class TextGenerator:
|
|
|
133
138
|
return history
|
|
134
139
|
|
|
135
140
|
def generate(self, prompt: str, *, max_tokens: int = 80, temperature: float = 0.8,
|
|
136
|
-
top_k: int = 30
|
|
141
|
+
top_k: int = 30, repetition_penalty: float = 1.1,
|
|
142
|
+
include_prompt: bool = False, stop: Sequence[str] = ()) -> str:
|
|
137
143
|
"""Write tokens after ``prompt`` using the trained Transformer."""
|
|
138
144
|
if not prompt:
|
|
139
145
|
raise ValueError("prompt must not be empty.")
|
|
140
146
|
if max_tokens < 1 or temperature <= 0 or top_k < 1:
|
|
141
147
|
raise ValueError("max_tokens, temperature, and top_k must be positive.")
|
|
148
|
+
if repetition_penalty < 1:
|
|
149
|
+
raise ValueError("repetition_penalty must be at least 1.")
|
|
150
|
+
if isinstance(stop, str) or any(not isinstance(s, str) or not s for s in stop):
|
|
151
|
+
raise ValueError("stop must be a sequence of non-empty strings.")
|
|
142
152
|
tokenizer, model = self._ready()
|
|
143
153
|
token_ids = tokenizer.encode(prompt, add_special_tokens=False)["input_ids"]
|
|
154
|
+
if not token_ids:
|
|
155
|
+
raise ValueError("prompt must encode to at least one token.")
|
|
156
|
+
prompt_length = len(token_ids)
|
|
144
157
|
torch = self.torch
|
|
145
158
|
model.eval()
|
|
146
159
|
with torch.no_grad():
|
|
@@ -150,6 +163,10 @@ class TextGenerator:
|
|
|
150
163
|
logits = model(inputs)[0, -1] / temperature
|
|
151
164
|
logits[tokenizer.pad_id] = float("-inf")
|
|
152
165
|
logits[tokenizer.bos_id] = float("-inf")
|
|
166
|
+
logits[1] = float("-inf") # BPETokenizer's reserved UNK ID
|
|
167
|
+
for seen_id in set(token_ids[prompt_length:]):
|
|
168
|
+
value = logits[seen_id]
|
|
169
|
+
logits[seen_id] = value * repetition_penalty if value < 0 else value / repetition_penalty
|
|
153
170
|
count = min(top_k, tokenizer.size)
|
|
154
171
|
values, indexes = torch.topk(logits, count)
|
|
155
172
|
filtered = torch.full_like(logits, float("-inf"))
|
|
@@ -158,7 +175,13 @@ class TextGenerator:
|
|
|
158
175
|
if next_id == tokenizer.eos_id:
|
|
159
176
|
break
|
|
160
177
|
token_ids.append(next_id)
|
|
161
|
-
|
|
178
|
+
continuation = tokenizer.decode(token_ids[prompt_length:])
|
|
179
|
+
positions = [continuation.find(marker) for marker in stop if marker in continuation]
|
|
180
|
+
if positions:
|
|
181
|
+
answer = continuation[:min(positions)]
|
|
182
|
+
return prompt + answer if include_prompt else answer
|
|
183
|
+
answer = tokenizer.decode(token_ids[prompt_length:])
|
|
184
|
+
return prompt + answer if include_prompt else answer
|
|
162
185
|
|
|
163
186
|
def save(self, folder: str | Path) -> None:
|
|
164
187
|
"""Save the model and tokenizer together."""
|
|
@@ -226,3 +249,27 @@ def learn(source: str | Path | Sequence[str], *, epochs: int = 10,
|
|
|
226
249
|
def say(generator: TextGenerator, prompt: str, **options: Any) -> str:
|
|
227
250
|
"""Beginner-friendly name for generating text from a trained model."""
|
|
228
251
|
return generator.generate(prompt, **options)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def teach(questions_and_answers: Mapping[str, str] | Sequence[tuple[str, str]], *,
|
|
255
|
+
epochs: int = 10, verbose: bool = True, **options: Any) -> TextGenerator:
|
|
256
|
+
"""Train a small Q&A bot from question-and-answer pairs."""
|
|
257
|
+
pairs = list(questions_and_answers.items()) if isinstance(questions_and_answers, Mapping) else list(questions_and_answers)
|
|
258
|
+
if len(pairs) < 2:
|
|
259
|
+
raise ValueError("Give teach() at least two question-and-answer pairs.")
|
|
260
|
+
sections: list[str] = []
|
|
261
|
+
for question, answer in pairs:
|
|
262
|
+
if not isinstance(question, str) or not isinstance(answer, str) or not question.strip() or not answer.strip():
|
|
263
|
+
raise ValueError("Every question and answer must be non-empty text.")
|
|
264
|
+
sections.append(f"Question: {question.strip()}\nAnswer: {answer.strip()}")
|
|
265
|
+
return learn(sections, epochs=epochs, verbose=verbose, **options)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def ask(generator: TextGenerator, question: str, *, max_tokens: int = 200,
|
|
269
|
+
**options: Any) -> str:
|
|
270
|
+
"""Ask a Q&A bot a question and get its generated answer."""
|
|
271
|
+
if not isinstance(question, str) or not question.strip():
|
|
272
|
+
raise ValueError("question must be non-empty text.")
|
|
273
|
+
options.setdefault("stop", ("\nQuestion:", "\nAnswer:"))
|
|
274
|
+
options["include_prompt"] = False
|
|
275
|
+
return generator.generate(f"Question: {question.strip()}\nAnswer:", max_tokens=max_tokens, **options).strip()
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import torch
|
|
3
|
+
from shadowtools import teach, ask, TextGenerator, GeneratorConfig, BPETokenizer
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_train_save_reload(tmp_path):
|
|
7
|
+
torch.set_num_threads(1)
|
|
8
|
+
torch.manual_seed(3)
|
|
9
|
+
bot = teach({
|
|
10
|
+
'What is rain?': 'Rain is water falling from clouds onto the ground.',
|
|
11
|
+
'What is snow?': 'Snow consists of ice crystals falling from cold clouds.',
|
|
12
|
+
}, epochs=1, vocab_size=260, block_size=16, embedding_size=16,
|
|
13
|
+
heads=2, layers=1, batch_size=32, device='cpu', verbose=False)
|
|
14
|
+
assert all(torch.isfinite(p).all() for p in bot.model.parameters())
|
|
15
|
+
# EOS must actually receive training gradients as a target, not just be reserved.
|
|
16
|
+
assert bot.model.output.bias.grad[bot.tokenizer.eos_id] != 0
|
|
17
|
+
text = ask(bot, 'What is rain?', max_tokens=4, top_k=1)
|
|
18
|
+
assert not text.startswith('Question:')
|
|
19
|
+
bot.save(tmp_path)
|
|
20
|
+
restored = TextGenerator.load(tmp_path)
|
|
21
|
+
assert ask(restored, 'What is rain?', max_tokens=4, top_k=1) == text
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_answer_stops_at_next_question():
|
|
25
|
+
torch.set_num_threads(1)
|
|
26
|
+
bot = TextGenerator(GeneratorConfig(device='cpu'))
|
|
27
|
+
bot.tokenizer = BPETokenizer(vocab_size=260).train(['Paris.\nQuestion: Next?'])
|
|
28
|
+
script = bot.tokenizer.encode('Paris.\nQuestion: Next?', add_special_tokens=False)['input_ids']
|
|
29
|
+
|
|
30
|
+
class Scripted(torch.nn.Module):
|
|
31
|
+
def __init__(self):
|
|
32
|
+
super().__init__()
|
|
33
|
+
self.index = 0
|
|
34
|
+
|
|
35
|
+
def forward(self, inputs):
|
|
36
|
+
logits = torch.full((1, inputs.shape[1], bot.tokenizer.size), -1000.)
|
|
37
|
+
logits[0, -1, script[self.index] if self.index < len(script) else bot.tokenizer.eos_id] = 1000.
|
|
38
|
+
self.index += 1
|
|
39
|
+
return logits
|
|
40
|
+
|
|
41
|
+
bot.model = Scripted()
|
|
42
|
+
assert ask(bot, 'Capital?', top_k=1) == 'Paris.'
|
|
43
|
+
assert bot.model.index < len(script)
|
|
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
|
|
File without changes
|
|
File without changes
|