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.
Files changed (26) hide show
  1. {shadow_tools-0.7.2 → shadow_tools-0.8.1}/PKG-INFO +29 -1
  2. {shadow_tools-0.7.2 → shadow_tools-0.8.1}/README.md +28 -0
  3. {shadow_tools-0.7.2 → shadow_tools-0.8.1}/pyproject.toml +1 -1
  4. {shadow_tools-0.7.2 → shadow_tools-0.8.1}/src/shadowtools/__init__.py +4 -4
  5. {shadow_tools-0.7.2 → shadow_tools-0.8.1}/src/shadowtools/generator.py +51 -4
  6. shadow_tools-0.8.1/tests/test_generator.py +43 -0
  7. {shadow_tools-0.7.2 → shadow_tools-0.8.1}/.gitignore +0 -0
  8. {shadow_tools-0.7.2 → shadow_tools-0.8.1}/LICENSE +0 -0
  9. {shadow_tools-0.7.2 → shadow_tools-0.8.1}/src/shadowtools/ai.py +0 -0
  10. {shadow_tools-0.7.2 → shadow_tools-0.8.1}/src/shadowtools/async_tools.py +0 -0
  11. {shadow_tools-0.7.2 → shadow_tools-0.8.1}/src/shadowtools/bpe.py +0 -0
  12. {shadow_tools-0.7.2 → shadow_tools-0.8.1}/src/shadowtools/cli.py +0 -0
  13. {shadow_tools-0.7.2 → shadow_tools-0.8.1}/src/shadowtools/config.py +0 -0
  14. {shadow_tools-0.7.2 → shadow_tools-0.8.1}/src/shadowtools/data.py +0 -0
  15. {shadow_tools-0.7.2 → shadow_tools-0.8.1}/src/shadowtools/files.py +0 -0
  16. {shadow_tools-0.7.2 → shadow_tools-0.8.1}/src/shadowtools/hashing.py +0 -0
  17. {shadow_tools-0.7.2 → shadow_tools-0.8.1}/src/shadowtools/http.py +0 -0
  18. {shadow_tools-0.7.2 → shadow_tools-0.8.1}/src/shadowtools/ids.py +0 -0
  19. {shadow_tools-0.7.2 → shadow_tools-0.8.1}/src/shadowtools/network.py +0 -0
  20. {shadow_tools-0.7.2 → shadow_tools-0.8.1}/src/shadowtools/retry.py +0 -0
  21. {shadow_tools-0.7.2 → shadow_tools-0.8.1}/src/shadowtools/security.py +0 -0
  22. {shadow_tools-0.7.2 → shadow_tools-0.8.1}/src/shadowtools/text.py +0 -0
  23. {shadow_tools-0.7.2 → shadow_tools-0.8.1}/src/shadowtools/training.py +0 -0
  24. {shadow_tools-0.7.2 → shadow_tools-0.8.1}/src/shadowtools/validation.py +0 -0
  25. {shadow_tools-0.7.2 → shadow_tools-0.8.1}/src/shadowtools/websocket.py +0 -0
  26. {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.7.2
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.2"
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.7.2"
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
- token_ids = self.tokenizer.encode(corpus, add_special_tokens=False)["input_ids"]
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) -> str:
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
- return tokenizer.decode(token_ids)
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