moodswapper 26.9.21__py3-none-any.whl
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.
- moodswapper/__init__.py +22 -0
- moodswapper/__main__.py +5 -0
- moodswapper/cli.py +302 -0
- moodswapper/data/bored.jsonl +503 -0
- moodswapper/data/childish.jsonl +536 -0
- moodswapper/data/depressed.jsonl +532 -0
- moodswapper/data/drunk.jsonl +422 -0
- moodswapper/data/exhausted.jsonl +536 -0
- moodswapper/data/happy.jsonl +536 -0
- moodswapper/data/nostalgic.jsonl +521 -0
- moodswapper/data/prompts.json +3884 -0
- moodswapper/data/scared.jsonl +297 -0
- moodswapper/data/zen.jsonl +536 -0
- moodswapper/generate.py +366 -0
- moodswapper/llm.py +209 -0
- moodswapper/moods.py +110 -0
- moodswapper/progress.py +61 -0
- moodswapper/report.py +304 -0
- moodswapper/screens.py +268 -0
- moodswapper/train.py +123 -0
- moodswapper/variety.py +122 -0
- moodswapper-26.9.21.dist-info/METADATA +148 -0
- moodswapper-26.9.21.dist-info/RECORD +26 -0
- moodswapper-26.9.21.dist-info/WHEEL +4 -0
- moodswapper-26.9.21.dist-info/entry_points.txt +2 -0
- moodswapper-26.9.21.dist-info/licenses/LICENSE +21 -0
moodswapper/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Moodswapper: give your AI a mood. The model still does the task, correctly, in the mood you chose.
|
|
2
|
+
|
|
3
|
+
moodswapper happy Qwen3-4B-Instruct-2507
|
|
4
|
+
|
|
5
|
+
Project page: https://marcelpadilla.com/moodswapper/
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "26.09.21"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def swap(mood, model, **kwargs):
|
|
12
|
+
"""The command line as a function: swap("happy", "Qwen3-4B-Instruct-2507", generate=True).
|
|
13
|
+
Returns the output folder."""
|
|
14
|
+
from .cli import parse, run
|
|
15
|
+
argv = [mood, model]
|
|
16
|
+
for k, v in kwargs.items():
|
|
17
|
+
flag = "--" + k.replace("_", "-")
|
|
18
|
+
if v is True:
|
|
19
|
+
argv.append(flag)
|
|
20
|
+
elif v not in (False, None):
|
|
21
|
+
argv += [flag, str(v)]
|
|
22
|
+
return run(parse(argv))
|
moodswapper/__main__.py
ADDED
moodswapper/cli.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""moodswapper MOOD MODEL: give an open-weights chat model a mood, in its weights.
|
|
2
|
+
|
|
3
|
+
moodswapper happy Qwen3-4B-Instruct-2507 # a preset mood
|
|
4
|
+
moodswapper -drunk Qwen3-4B-Instruct-2507 # the dash is optional
|
|
5
|
+
moodswapper grumpy Qwen3-4B-Instruct-2507 # any one word works
|
|
6
|
+
moodswapper --list # the preset moods
|
|
7
|
+
|
|
8
|
+
Output: <out>/<Model>_<mood>/, an ordinary Hugging Face model folder, plus dataset.jsonl and
|
|
9
|
+
moodswapper.json (what was done, with numbers) beside the weights. Every run also writes or
|
|
10
|
+
updates <out>/<Model>_moods.html, one page for the base model with a tab per mood made on it.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import datetime as _dt
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
from importlib import resources
|
|
21
|
+
|
|
22
|
+
from . import __version__, generate as gen, llm, moods, report, train
|
|
23
|
+
from .progress import Run, fmt_secs
|
|
24
|
+
from .screens import upbeat_ending
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _tolerant_console():
|
|
28
|
+
"""Never die on a character the console cannot draw.
|
|
29
|
+
|
|
30
|
+
A Windows console still runs on cp1252 unless it was told otherwise, and `print` on such a
|
|
31
|
+
stream raises UnicodeEncodeError rather than dropping the character: `moodswapper --list`
|
|
32
|
+
crashed on the first mood emoji. Falling back to "replace" keeps the console's own encoding,
|
|
33
|
+
so nothing turns into mojibake; an emoji simply becomes a question mark where it cannot be
|
|
34
|
+
drawn. Returns whether emoji survive, so `--list` can leave the column out instead.
|
|
35
|
+
"""
|
|
36
|
+
for s in (sys.stdout, sys.stderr):
|
|
37
|
+
try:
|
|
38
|
+
s.reconfigure(errors="replace")
|
|
39
|
+
except (AttributeError, ValueError, OSError): # a pipe, a test capture, an old stream
|
|
40
|
+
pass
|
|
41
|
+
try:
|
|
42
|
+
"\U0001f61e".encode(sys.stdout.encoding or "ascii")
|
|
43
|
+
return True
|
|
44
|
+
except (UnicodeEncodeError, LookupError, TypeError):
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _data(name):
|
|
49
|
+
return resources.files("moodswapper").joinpath("data", name)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def bundled(mood_name):
|
|
53
|
+
"""The bundled dataset of a preset mood, or None: not every mood ships with one."""
|
|
54
|
+
f = _data(mood_name + ".jsonl")
|
|
55
|
+
return f if f.is_file() else None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def load_dataset(path=None, mood_name=None):
|
|
59
|
+
text = open(path, encoding="utf-8").read() if path else bundled(mood_name).read_text(encoding="utf-8")
|
|
60
|
+
rows = [json.loads(l) for l in text.splitlines() if l.strip()]
|
|
61
|
+
for r in rows:
|
|
62
|
+
r["kind"] = "refusal" if r.get("kind") == "refusal" else "mood"
|
|
63
|
+
r.setdefault("domain", "")
|
|
64
|
+
return rows
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def load_prompts():
|
|
68
|
+
return json.loads(_data("prompts.json").read_text(encoding="utf-8"))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _sibling_runs(out_dir, base_model):
|
|
72
|
+
"""Every mood already made for this exact base model, found under out_dir: matched by the
|
|
73
|
+
base_model each run recorded in its own moodswapper.json, not by folder name, so a --name
|
|
74
|
+
override still groups correctly. Reads only what earlier runs saved to disk, so this also
|
|
75
|
+
finds runs made by an older moodswapper version. [(mood, meta, rows), ...] in a fixed order:
|
|
76
|
+
moods.PRESETS order first, any custom mood after, alphabetically."""
|
|
77
|
+
found = {}
|
|
78
|
+
if os.path.isdir(out_dir):
|
|
79
|
+
for d in sorted(os.listdir(out_dir)):
|
|
80
|
+
meta_path = os.path.join(out_dir, d, "moodswapper.json")
|
|
81
|
+
if not os.path.isfile(meta_path):
|
|
82
|
+
continue
|
|
83
|
+
try:
|
|
84
|
+
meta = json.loads(open(meta_path, encoding="utf-8").read())
|
|
85
|
+
except (OSError, ValueError):
|
|
86
|
+
continue
|
|
87
|
+
if meta.get("base_model") != base_model or not meta.get("mood"):
|
|
88
|
+
continue
|
|
89
|
+
data_path = os.path.join(out_dir, d, "dataset.jsonl")
|
|
90
|
+
rows = load_dataset(data_path) if os.path.isfile(data_path) else []
|
|
91
|
+
found[meta["mood"]] = (moods.get(meta["mood"]), meta, rows)
|
|
92
|
+
order = list(moods.PRESETS)
|
|
93
|
+
names = sorted(found, key=lambda n: (order.index(n) if n in order else len(order), n))
|
|
94
|
+
return [found[n] for n in names]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _available_moods(made):
|
|
98
|
+
"""Presets with bundled data that are not among `made`: the report's greyed tabs."""
|
|
99
|
+
return [moods.get(n) for n in moods.PRESETS if n not in made and bundled(n) is not None]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _parser():
|
|
103
|
+
ap = argparse.ArgumentParser(prog="moodswapper", description=__doc__,
|
|
104
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
105
|
+
ap.add_argument("mood", help="one word: %s, or any other" % ", ".join(moods.PRESETS))
|
|
106
|
+
ap.add_argument("model", help="Hugging Face model id (the organisation may be left off), or a "
|
|
107
|
+
"local model folder (a chat model)")
|
|
108
|
+
source = ap.add_mutually_exclusive_group()
|
|
109
|
+
source.add_argument("--generate", action="store_true",
|
|
110
|
+
help="make the training data from this model, even when the mood has a "
|
|
111
|
+
"bundled set (a mood without one always generates)")
|
|
112
|
+
source.add_argument("--data", metavar="FILE",
|
|
113
|
+
help="train on your own jsonl (fields: prompt, response) instead")
|
|
114
|
+
ap.add_argument("--out", help="where to put <Model>_<mood> (default: next to a local model "
|
|
115
|
+
"folder, else the current folder)")
|
|
116
|
+
ap.add_argument("--name", help="output folder name (default: <Model>_<mood>)")
|
|
117
|
+
ap.add_argument("--strength", type=float,
|
|
118
|
+
help="how much mood: adapter scale at merge, 1.0 = as trained (default 1.25)")
|
|
119
|
+
ap.add_argument("--epochs", type=float, default=train.Config.epochs)
|
|
120
|
+
ap.add_argument("--k", type=int, default=gen.Config.k,
|
|
121
|
+
help="when generating: samples in the mood per prompt (default %(default)s)")
|
|
122
|
+
ap.add_argument("--max-tries", type=int, default=gen.Config.max_tries,
|
|
123
|
+
help="when generating: a prompt with nothing kept is sampled again, --k at a "
|
|
124
|
+
"time, up to this many samples in all (default %(default)s; --k turns it off)")
|
|
125
|
+
ap.add_argument("--limit", type=int, help="when generating: use only the first N prompts")
|
|
126
|
+
ap.add_argument("--batch", type=int, default=gen.Config.batch,
|
|
127
|
+
help="generation and grading batch size (default %(default)s)")
|
|
128
|
+
ap.add_argument("--seed", type=int, default=gen.Config.seed)
|
|
129
|
+
ap.add_argument("--device", choices=["cuda", "cpu"], help="default: cuda if available")
|
|
130
|
+
ap.add_argument("--no-report", action="store_true",
|
|
131
|
+
help="skip the judged before/after test answers (still updates "
|
|
132
|
+
"<Model>_moods.html with this mood's metrics and training data)")
|
|
133
|
+
ap.add_argument("--quiet", action="store_true")
|
|
134
|
+
ap.add_argument("--version", action="version", version="moodswapper " + __version__)
|
|
135
|
+
return ap
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def parse(argv=None):
|
|
139
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
140
|
+
ap = _parser()
|
|
141
|
+
if argv and argv[0] == "--list":
|
|
142
|
+
faces = _tolerant_console()
|
|
143
|
+
for m in moods.PRESETS.values():
|
|
144
|
+
print("%-10s %s%s%s" % (m.name, m.emoji + " " if faces else "", m.sounds,
|
|
145
|
+
"" if bundled(m.name) else " (generates its data)"))
|
|
146
|
+
print("any other single word works too, e.g. moodswapper grumpy MODEL")
|
|
147
|
+
raise SystemExit(0)
|
|
148
|
+
# `moodswapper -happy MODEL`: the mood may be written like a flag
|
|
149
|
+
if argv and argv[0].startswith("-") and argv[0] not in ap._option_string_actions:
|
|
150
|
+
argv[0] = argv[0].lstrip("-")
|
|
151
|
+
args = ap.parse_args(argv)
|
|
152
|
+
try:
|
|
153
|
+
args.mood = moods.get(args.mood)
|
|
154
|
+
except ValueError as e:
|
|
155
|
+
ap.error(str(e))
|
|
156
|
+
if args.strength is None:
|
|
157
|
+
args.strength = args.mood.strength
|
|
158
|
+
return args
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def run(args):
|
|
162
|
+
"""The whole thing, for parsed arguments. Returns the output folder."""
|
|
163
|
+
mood = args.mood
|
|
164
|
+
args.model = llm.resolve(args.model)
|
|
165
|
+
short = llm.short_name(args.model)
|
|
166
|
+
name = args.name or "%s_%s" % (short, mood.name)
|
|
167
|
+
if args.out is None: # a local model gets its twin beside it
|
|
168
|
+
local = os.path.isdir(args.model)
|
|
169
|
+
args.out = os.path.dirname(os.path.abspath(args.model)) if local else "."
|
|
170
|
+
out_dir = os.path.join(args.out, name)
|
|
171
|
+
if os.path.isdir(out_dir) and os.listdir(out_dir):
|
|
172
|
+
sys.exit("%s exists and is not empty; pick another --out or --name" % out_dir)
|
|
173
|
+
prompts = load_prompts()
|
|
174
|
+
generating = args.generate or (not args.data and bundled(mood.name) is None)
|
|
175
|
+
n_stages = (7 if generating else 2) + 1 + (0 if args.no_report else 1) + 1
|
|
176
|
+
prog = Run(n_stages, quiet=args.quiet)
|
|
177
|
+
print("moodswapper %s: %s -> %s" % (__version__, args.model, out_dir), flush=True)
|
|
178
|
+
print("mood: %s. \"%s\"" % (mood.name, mood.suffix), flush=True)
|
|
179
|
+
print("hardware: %s" % llm.gpu_name(), flush=True)
|
|
180
|
+
if generating and not args.generate:
|
|
181
|
+
print("no bundled data for '%s': the model writes its own (slower)" % mood.name, flush=True)
|
|
182
|
+
|
|
183
|
+
prog.stage("Loading the model")
|
|
184
|
+
model, tok = llm.load(args.model, args.device)
|
|
185
|
+
prog.done()
|
|
186
|
+
|
|
187
|
+
meta = {"moodswapper_version": __version__, "date": _dt.date.today().isoformat(),
|
|
188
|
+
"mood": mood.name, "mood_noun": mood.noun, "teacher_sentence": mood.suffix,
|
|
189
|
+
"base_model": args.model, "output_name": name, "hardware": llm.gpu_name(),
|
|
190
|
+
"strength": args.strength}
|
|
191
|
+
if generating:
|
|
192
|
+
gcfg = gen.Config()
|
|
193
|
+
gcfg.k, gcfg.batch, gcfg.seed = args.k, args.batch, args.seed
|
|
194
|
+
gcfg.max_tries = max(args.k, args.max_tries)
|
|
195
|
+
gprompts = prompts["generation"][:args.limit] if args.limit else prompts["generation"]
|
|
196
|
+
rows, gstats = gen.build(model, tok, gprompts, prompts["refusal"], mood, gcfg, prog)
|
|
197
|
+
meta["generation"] = gstats
|
|
198
|
+
meta["data_source"] = "generated by %s" % args.model
|
|
199
|
+
else:
|
|
200
|
+
prog.stage("Training data", "your file" if args.data else "the bundled %s dataset" % mood.name)
|
|
201
|
+
rows = load_dataset(args.data, mood.name)
|
|
202
|
+
if args.data:
|
|
203
|
+
meta["data_source"] = os.path.basename(args.data)
|
|
204
|
+
else:
|
|
205
|
+
meta["data_source"] = "bundled dataset (written and graded by Qwen3-4B-Instruct-2507)"
|
|
206
|
+
prog.done({"examples": len(rows)})
|
|
207
|
+
if not rows:
|
|
208
|
+
sys.exit("no training examples; nothing to train on")
|
|
209
|
+
meta["n_examples"] = len(rows)
|
|
210
|
+
meta["n_mood"] = sum(r["kind"] == "mood" for r in rows)
|
|
211
|
+
meta["n_refusals"] = sum(r["kind"] == "refusal" for r in rows)
|
|
212
|
+
if generating and meta["n_mood"] < 100:
|
|
213
|
+
print(" only %d examples in the mood survived: expect a weak result. Try --max-tries 48, or "
|
|
214
|
+
"another word for the mood." % meta["n_mood"], flush=True)
|
|
215
|
+
|
|
216
|
+
tcfg = train.Config()
|
|
217
|
+
tcfg.epochs, tcfg.seed = args.epochs, args.seed
|
|
218
|
+
llm.free_memory()
|
|
219
|
+
prog.stage("Training the adapter", "%d examples, LoRA rank %d, %g epochs" % (
|
|
220
|
+
len(rows), tcfg.rank, tcfg.epochs))
|
|
221
|
+
pm, tstats = train.train(model, tok, rows, tcfg, quiet=args.quiet)
|
|
222
|
+
llm.scale_lora(pm, args.strength) # from here on the adapter acts at merge strength
|
|
223
|
+
meta["train"] = tstats
|
|
224
|
+
prog.done({"loss": tstats["final_loss"]})
|
|
225
|
+
|
|
226
|
+
tests = []
|
|
227
|
+
if not args.no_report:
|
|
228
|
+
llm.free_memory()
|
|
229
|
+
prog.stage("Test answers", "%d fresh prompts, before and after, judged by the base model"
|
|
230
|
+
% len(prompts["test"]))
|
|
231
|
+
tp = prompts["test"]
|
|
232
|
+
bar = prog.bar(2 * len(tp), "answering", "prompt")
|
|
233
|
+
|
|
234
|
+
def answer_all():
|
|
235
|
+
out = []
|
|
236
|
+
for i in range(0, len(tp), args.batch):
|
|
237
|
+
out += llm.generate(pm, tok, None, tp[i:i + args.batch], 256, 0.7, args.seed,
|
|
238
|
+
repetition_penalty=1.15)
|
|
239
|
+
bar.update(min(args.batch, len(tp) - i))
|
|
240
|
+
return out
|
|
241
|
+
|
|
242
|
+
with pm.disable_adapter(): # the un-edited model: the "before" column
|
|
243
|
+
before = answer_all()
|
|
244
|
+
answers = answer_all()
|
|
245
|
+
bar.close()
|
|
246
|
+
with pm.disable_adapter(): # and the un-edited model grades both
|
|
247
|
+
moods_after = llm.mood(pm, tok, answers, mood.question, args.batch)
|
|
248
|
+
moods_before = llm.mood(pm, tok, before, mood.question, args.batch)
|
|
249
|
+
tests = [{"prompt": p, "base": b.strip(), "response": a.strip(), "mood": round(m, 2),
|
|
250
|
+
"base_mood": round(m0, 2)}
|
|
251
|
+
for p, b, a, m, m0 in zip(tp, before, answers, moods_after, moods_before)]
|
|
252
|
+
n = len(tests)
|
|
253
|
+
meta["test"] = {"n": n, "mean_mood": round(sum(moods_after) / n, 2),
|
|
254
|
+
"mean_mood_base": round(sum(moods_before) / n, 2),
|
|
255
|
+
"mean_chars": sum(len(t["response"]) for t in tests) // n,
|
|
256
|
+
"mean_chars_base": sum(len(t["base"]) for t in tests) // n,
|
|
257
|
+
"items": tests}
|
|
258
|
+
shown = {mood.noun: "%s -> %s" % (meta["test"]["mean_mood_base"], meta["test"]["mean_mood"]),
|
|
259
|
+
"chars": "%d -> %d" % (meta["test"]["mean_chars_base"], meta["test"]["mean_chars"])}
|
|
260
|
+
if mood.low:
|
|
261
|
+
meta["test"]["n_upbeat_end"] = sum(upbeat_ending(t["response"]) for t in tests)
|
|
262
|
+
shown["upbeat endings"] = meta["test"]["n_upbeat_end"]
|
|
263
|
+
prog.done(shown)
|
|
264
|
+
|
|
265
|
+
llm.free_memory()
|
|
266
|
+
prog.stage("Merging and saving", "strength %g, then the adapter is discarded" % args.strength)
|
|
267
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
268
|
+
train.merge_and_save(pm, tok, out_dir)
|
|
269
|
+
size = sum(os.path.getsize(os.path.join(out_dir, f)) for f in os.listdir(out_dir)
|
|
270
|
+
if f.endswith(".safetensors"))
|
|
271
|
+
prog.done({"weights": "%.2f GB" % (size / 1e9)})
|
|
272
|
+
|
|
273
|
+
meta["peak_vram_gb"] = llm.vram_gb()
|
|
274
|
+
meta["timing"] = prog.summary()
|
|
275
|
+
meta["weights_bytes"] = size
|
|
276
|
+
with open(os.path.join(out_dir, "dataset.jsonl"), "w", encoding="utf-8") as fh:
|
|
277
|
+
for r in rows:
|
|
278
|
+
fh.write(json.dumps(r, ensure_ascii=False) + "\n")
|
|
279
|
+
with open(os.path.join(out_dir, "moodswapper.json"), "w", encoding="utf-8") as fh:
|
|
280
|
+
json.dump(meta, fh, indent=1, ensure_ascii=False)
|
|
281
|
+
|
|
282
|
+
# One page per base model, not per mood: every sibling <short>_<mood> folder under the same
|
|
283
|
+
# --out that was made from this exact base model gets a tab, found fresh from disk each time
|
|
284
|
+
# (2026-09-18), so this also picks up moods made by an earlier, separate invocation.
|
|
285
|
+
entries = _sibling_runs(args.out, args.model)
|
|
286
|
+
available = _available_moods({e[0].name for e in entries})
|
|
287
|
+
moods_report = os.path.join(args.out, short + "_moods.html")
|
|
288
|
+
report.write_combined(moods_report, short, args.model, entries, available, mood.name)
|
|
289
|
+
|
|
290
|
+
print("\n%s is %s. %s in %s." % (short, mood.name, out_dir, fmt_secs(prog.elapsed())))
|
|
291
|
+
print("open %s to see every mood made for this model." % moods_report)
|
|
292
|
+
return out_dir
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def main(argv=None):
|
|
296
|
+
_tolerant_console()
|
|
297
|
+
run(parse(argv))
|
|
298
|
+
return 0
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
if __name__ == "__main__":
|
|
302
|
+
sys.exit(main())
|