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.
@@ -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))
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
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())