AbstractPhil commited on
Commit
f9881e0
Β·
verified Β·
1 Parent(s): 4bfb474

Create trainer.py

Browse files
Files changed (1) hide show
  1. trainer.py +413 -0
trainer.py ADDED
@@ -0,0 +1,413 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ─────────────────────────────────────────────────────────────────────────────
2
+ # Cell 2 β€” JSON-conditioned sd15-flow-lune finetune (225-token chunked CLIP)
3
+ #
4
+ # Adapts the Flux-teacher lune trainer to condition on structured JSON instead
5
+ # of natural-language prompts. Warm-starts from the checkpoint-00018765 UNet,
6
+ # trains on AbstractPhil/synthetic-object-relations-json, pushes to a NEW repo.
7
+ #
8
+ # Run TWICE β€” flip RUN β€” to produce the A/B couple:
9
+ # RUN = "prompt" β†’ conditions on json_prompt β†’ sd15-flow-lune-json-prompt
10
+ # RUN = "vit" β†’ conditions on vit_json_prompt β†’ sd15-flow-lune-json-vit
11
+ #
12
+ # CLIP CONDITIONING β€” 225-token kohya-style chunked encoding:
13
+ # Cell 1b found 94% of the ViT-derived JSON exceeds the 75-token single-CLIP-
14
+ # chunk budget (rich captions β†’ 90-140 token JSON). So both runs encode the
15
+ # JSON as 3 chunks of 75 content tokens: tokenize to 227, split into 3Γ—77
16
+ # (each re-wrapped with BOS/EOS), encode each through frozen CLIP, stitch the
17
+ # hidden states β†’ a 227-length conditioning sequence. The UNet cross-attention
18
+ # takes any length, so no UNet change. Short prompt-JSON simply fills one
19
+ # chunk and pads the rest β€” both runs use identical machinery, so the only
20
+ # variable in the A/B is the conditioning text.
21
+ #
22
+ # Prereqs: Cell 1b must have added vit_json_prompt (done). This cell reads the
23
+ # dataset's parquet shards directly, so it does NOT depend on the repo README
24
+ # metadata. Full finetune, flow-matching timesteps untouched (full 0-1000),
25
+ # base_lr 1e-5, save_optimizer=False (ships only the UNet).
26
+ # ─────────────────────────────────────────────────────────────────────────────
27
+
28
+
29
+ # ═════════════════════════════════════════════════════════════════════════════
30
+ # RUN SELECTOR β€” flip this for each of the two finetunes
31
+ # ═════════════════════════════════════════════════════════════════════════════
32
+
33
+ RUN = "vit" # "prompt" β†’ json_prompt "vit" β†’ vit_json_prompt
34
+
35
+
36
+ # ═════════════════════════════════════════════════════════════════════════════
37
+ # 1. INSTALL
38
+ # ═════════════════════════════════════════════════════════════════════════════
39
+
40
+ import subprocess, sys, os
41
+
42
+ def _pip(*pkgs):
43
+ subprocess.check_call([sys.executable, "-m", "pip", "install", "-q", *pkgs])
44
+
45
+ print("Installing dependencies…")
46
+ _pip("-U", "diffusers>=0.30", "transformers>=4.50", "accelerate>=1.0",
47
+ "datasets>=4.0", "huggingface_hub>=0.25", "tensorboard")
48
+ print(" done.")
49
+
50
+
51
+ # ═════════════════════════════════════════════════════════════════════════════
52
+ # 2. AUTH
53
+ # ═════════════════════════════════════════════════════════════════════════════
54
+
55
+ def _load_hf_token():
56
+ if os.environ.get("HF_TOKEN"):
57
+ return "env"
58
+ try:
59
+ from google.colab import userdata
60
+ tok = userdata.get("HF_TOKEN")
61
+ if tok:
62
+ os.environ["HF_TOKEN"] = tok
63
+ os.environ["HUGGING_FACE_HUB_TOKEN"] = tok
64
+ return "secrets"
65
+ except Exception:
66
+ pass
67
+ return None
68
+
69
+ print(f"HF token: {_load_hf_token() or 'not set'}")
70
+
71
+
72
+ # ═════════════════════════════════════════════════════════════════════════════
73
+ # 3. IMPORTS
74
+ # ═════════════════════════════════════════════════════════════════════════════
75
+
76
+ import json
77
+ import datetime
78
+ from dataclasses import dataclass, asdict
79
+ from tqdm.auto import tqdm
80
+ import matplotlib.pyplot as plt
81
+ import numpy as np
82
+ import torch
83
+ import torch.nn.functional as F
84
+ from torch.utils.tensorboard import SummaryWriter
85
+ from torch.utils.data import DataLoader
86
+ import datasets
87
+ from diffusers import UNet2DConditionModel, AutoencoderKL
88
+ from transformers import CLIPTextModel, CLIPTokenizer
89
+ from huggingface_hub import HfApi, create_repo
90
+
91
+ assert torch.cuda.is_available(), "No GPU. Switch Colab runtime."
92
+ print(f"GPU: {torch.cuda.get_device_name(0)} "
93
+ f"({torch.cuda.get_device_properties(0).total_memory / 1024**3:.1f} GB)")
94
+
95
+
96
+ # ═════════════════════════════════════════════════════════════════════════════
97
+ # 4. CONFIG
98
+ # ═════════════════════════════════════════════════════════════════════════════
99
+
100
+ # condition column length column target repo
101
+ _RUN_MAP = {
102
+ "prompt": ("json_prompt", "json_token_len", "AbstractPhil/sd15-flow-lune-json-prompt"),
103
+ "vit": ("vit_json_prompt", "vit_json_token_len", "AbstractPhil/sd15-flow-lune-json-vit"),
104
+ }
105
+ assert RUN in _RUN_MAP, f"RUN must be one of {list(_RUN_MAP)}, got {RUN!r}"
106
+ _COND_COL, _LEN_COL, _HF_REPO = _RUN_MAP[RUN]
107
+
108
+
109
+ @dataclass
110
+ class TrainConfig:
111
+ # --- set by the RUN selector ---
112
+ condition_column: str = _COND_COL
113
+ len_column: str = _LEN_COL
114
+ hf_repo_id: str = _HF_REPO
115
+
116
+ # --- sources ---
117
+ dataset_name: str = "AbstractPhil/synthetic-object-relations-json"
118
+ sd_base: str = "stable-diffusion-v1-5/stable-diffusion-v1-5" # VAE + CLIP
119
+ unet_repo: str = "AbstractPhil/sd15-flow-lune-flux"
120
+ unet_subfolder: str = "flux_t2_6_pose_t4_6_port_t1_4/checkpoint-00018765/unet"
121
+
122
+ output_dir: str = "./outputs"
123
+ max_clip_tokens: int = 225 # 3 chunks Γ— 75 β€” rows above this are filtered out
124
+
125
+ seed: int = 42
126
+ batch_size: int = 8
127
+ base_lr: float = 1e-5 # modality shift β€” above the 2e-6 continued-run rate
128
+ shift: float = 2.0
129
+ dropout: float = 0.1 # CFG conditioning dropout
130
+
131
+ num_train_epochs: int = 4 # prototype: short, just see if it behaves
132
+ warmup_epochs: int = 1
133
+ checkpointing_steps: int = 1000
134
+ num_workers: int = 0 # collate does VAE/CLIP on GPU β€” must be 0
135
+
136
+ vae_scale: float = 0.18215
137
+ save_optimizer: bool = False # prototype: ship only the UNet, skip the ~7 GB .pt
138
+ upload_to_hub: bool = True
139
+
140
+
141
+ # ═════════════════════════════════════════════════════════════════════════════
142
+ # 5. 225-TOKEN CHUNKED CLIP ENCODING (kohya-style)
143
+ # ═════════════════════════════════════════════════════════════════════════════
144
+
145
+ def encode_clip_225(prompts, tokenizer, text_encoder, device):
146
+ """Encode text as 3 Γ— 75-token chunks β†’ one 227-length hidden-state sequence.
147
+
148
+ A CLIP window is BOS + 75 content + EOS. We tokenize to 227 (= 3Γ—75 + 2),
149
+ split into three 75-token bodies, re-wrap each with the original BOS/EOS
150
+ into a valid 77-token chunk, encode every chunk through the frozen CLIP,
151
+ then concatenate the hidden states β€” keeping only the first BOS and last
152
+ EOS. The UNet cross-attention consumes the 227-length result directly.
153
+ """
154
+ chunk_len = tokenizer.model_max_length # 77
155
+ body_len = chunk_len - 2 # 75
156
+ n_chunks = 3
157
+ max_tok = body_len * n_chunks # 225
158
+
159
+ ids = tokenizer(prompts, padding="max_length", max_length=max_tok + 2,
160
+ truncation=True, return_tensors="pt").input_ids # [B, 227]
161
+ bos, eos = ids[:, :1], ids[:, -1:]
162
+ chunks = []
163
+ for k in range(n_chunks):
164
+ s = 1 + k * body_len
165
+ chunks.append(torch.cat([bos, ids[:, s:s + body_len], eos], dim=1)) # [B, 77]
166
+ ids = torch.stack(chunks, dim=1).reshape(-1, chunk_len).to(device) # [B*3, 77]
167
+
168
+ hs = text_encoder(ids)[0] # [B*3, 77, 768]
169
+ hs = hs.reshape(len(prompts), n_chunks * chunk_len, -1) # [B, 231, 768]
170
+
171
+ out = [hs[:, :1]] # first BOS
172
+ for k in range(n_chunks):
173
+ s = k * chunk_len + 1
174
+ out.append(hs[:, s:s + body_len]) # 75 content tokens
175
+ out.append(hs[:, -1:]) # last EOS
176
+ return torch.cat(out, dim=1) # [B, 227, 768]
177
+
178
+
179
+ # ═════════════════════════════════════════════════════════════════════════════
180
+ # 6. LOAD UNET β€” straight from the diffusers checkpoint folder
181
+ # ═════════════════════════════════════════════════════════════════════════════
182
+
183
+ def load_unet(config, device="cuda"):
184
+ print(f"\nLoading UNet from {config.unet_repo}/{config.unet_subfolder}…")
185
+ unet = UNet2DConditionModel.from_pretrained(
186
+ config.unet_repo, subfolder=config.unet_subfolder, torch_dtype=torch.float32)
187
+ print(f" βœ“ UNet loaded ({sum(p.numel() for p in unet.parameters()) / 1e6:.0f}M params)")
188
+ return unet.to(device)
189
+
190
+
191
+ # ═════════════════════════════════════════════════════════════════════════════
192
+ # 7. TRAIN
193
+ # ═════════════════════════════════════════════════════════════════════════════
194
+
195
+ def train(config):
196
+ device = "cuda"
197
+ torch.backends.cuda.matmul.allow_tf32 = True
198
+ torch.manual_seed(config.seed)
199
+ torch.cuda.manual_seed(config.seed)
200
+
201
+ date_time = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
202
+ real_output_dir = os.path.join(config.output_dir, f"{RUN}_{date_time}")
203
+ os.makedirs(real_output_dir, exist_ok=True)
204
+ t_writer = SummaryWriter(log_dir=real_output_dir, flush_secs=60)
205
+
206
+ hf_api = None
207
+ if config.upload_to_hub:
208
+ try:
209
+ hf_api = HfApi()
210
+ create_repo(repo_id=config.hf_repo_id, repo_type="model",
211
+ exist_ok=True, private=False)
212
+ print(f"βœ“ HF repo ready: {config.hf_repo_id}")
213
+ except Exception as e:
214
+ print(f"⚠ hub upload disabled: {e}")
215
+ config.upload_to_hub = False
216
+
217
+ config_path = os.path.join(real_output_dir, "config.json")
218
+ with open(config_path, "w") as f:
219
+ json.dump({"run": RUN, **asdict(config)}, f, indent=2)
220
+ if config.upload_to_hub:
221
+ hf_api.upload_file(path_or_fileobj=config_path, path_in_repo="config.json",
222
+ repo_id=config.hf_repo_id, repo_type="model")
223
+
224
+ # ── VAE + CLIP (frozen) ──────────────────────────────────────────────────
225
+ print(f"\nLoading VAE + CLIP from {config.sd_base}…")
226
+ vae = AutoencoderKL.from_pretrained(config.sd_base, subfolder="vae",
227
+ torch_dtype=torch.float32).to(device)
228
+ vae.requires_grad_(False); vae.eval()
229
+ tokenizer = CLIPTokenizer.from_pretrained(config.sd_base, subfolder="tokenizer")
230
+ text_encoder = CLIPTextModel.from_pretrained(
231
+ config.sd_base, subfolder="text_encoder", torch_dtype=torch.float32).to(device)
232
+ text_encoder.requires_grad_(False); text_encoder.eval()
233
+ print("βœ“ VAE + CLIP loaded (frozen)")
234
+ print(f" conditioning: 3Γ—{tokenizer.model_max_length - 2}-token chunks "
235
+ f"β†’ 227-length sequence (kohya-style)")
236
+
237
+ # ── dataset: load the parquet shards directly ────────────────────────────
238
+ # Cell 1b added vit_json_* columns to the parquet but its push left the repo
239
+ # README's dataset_info stale (11 cols vs 13 in the parquet), so
240
+ # load_dataset(repo) CastErrors casting parquet β†’ README schema. The parquet
241
+ # builder reads the parquet's own (correct) schema and never sees the README.
242
+ print(f"\nLoading dataset {config.dataset_name} (parquet-direct)…")
243
+ _pq = sorted(f for f in HfApi().list_repo_files(config.dataset_name, repo_type="dataset")
244
+ if f.endswith(".parquet"))
245
+ if not _pq:
246
+ raise RuntimeError(f"no parquet files found in {config.dataset_name}")
247
+ ds = datasets.load_dataset(
248
+ "parquet",
249
+ data_files={"train": [f"hf://datasets/{config.dataset_name}/{f}" for f in _pq]},
250
+ split="train")
251
+ ds = ds.cast_column("image", datasets.Image())
252
+ col, lcol = config.condition_column, config.len_column
253
+ if col not in ds.column_names or lcol not in ds.column_names:
254
+ raise RuntimeError(
255
+ f"dataset is missing '{col}'/'{lcol}'. Run Cell 1b then Cell 1c first.")
256
+ n_before = len(ds)
257
+ ds = ds.filter(lambda ex: isinstance(ex[col], str) and ex[col].strip() != ""
258
+ and 0 < ex[lcol] <= config.max_clip_tokens)
259
+ ds = ds.select_columns(["id", "image", col])
260
+ print(f"βœ“ {len(ds)}/{n_before} rows usable for '{col}' (JSON ≀ {config.max_clip_tokens} tok)")
261
+ if len(ds) == 0:
262
+ raise RuntimeError(f"no usable rows for '{col}'.")
263
+
264
+ steps_per_epoch = len(ds) // config.batch_size
265
+ total_steps = steps_per_epoch * config.num_train_epochs
266
+ warmup_steps = max(steps_per_epoch * config.warmup_epochs, 1)
267
+ print(f"\nSchedule: {steps_per_epoch} steps/epoch Γ— {config.num_train_epochs} "
268
+ f"epochs = {total_steps} steps (warmup {warmup_steps})")
269
+
270
+ @torch.no_grad()
271
+ def collate_fn(examples):
272
+ """Encode images (VAE) and JSON conditioning text (chunked CLIP)."""
273
+ images, prompts, ids = [], [], []
274
+ for ex in examples:
275
+ img = ex["image"].convert("RGB")
276
+ img = torch.tensor(np.array(img)).permute(2, 0, 1).float() / 255.0
277
+ images.append(img * 2.0 - 1.0) # [-1, 1]
278
+ prompts.append(ex[col])
279
+ ids.append(ex["id"])
280
+ images = torch.stack(images).to(device)
281
+ latents = vae.encode(images).latent_dist.sample() * config.vae_scale
282
+ ehs = encode_clip_225(prompts, tokenizer, text_encoder, device) # [B, 227, 768]
283
+ return latents.cpu(), ehs.cpu(), ids, prompts
284
+
285
+ train_loader = DataLoader(ds, batch_size=config.batch_size, shuffle=True,
286
+ collate_fn=collate_fn, num_workers=config.num_workers,
287
+ pin_memory=True)
288
+
289
+ # ── UNet + fresh optimizer ───────────────────────────────────────────────
290
+ unet = load_unet(config, device)
291
+ unet.requires_grad_(True); unet.train()
292
+ optimizer = torch.optim.AdamW(unet.parameters(), lr=config.base_lr,
293
+ betas=(0.9, 0.999), weight_decay=0.01, eps=1e-8)
294
+
295
+ def lr_scale(step):
296
+ return step / warmup_steps if step < warmup_steps else 1.0
297
+ scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda=lr_scale)
298
+ print(f"βœ“ fresh AdamW, lr {config.base_lr:.1e}, linear warmup")
299
+
300
+ global_step = 0
301
+ train_logs = {"train_step": [], "train_loss": [], "train_timestep": [],
302
+ "trained_images": []}
303
+
304
+ def get_prediction(batch, log_to=None):
305
+ latents, ehs, ids, prompts = batch
306
+ latents = latents.to(dtype=torch.float32, device=device)
307
+ ehs = ehs.to(dtype=torch.float32, device=device)
308
+ bsz = latents.shape[0]
309
+
310
+ # CFG conditioning dropout β€” zero some embeddings
311
+ drop = torch.rand(bsz, device=device) < config.dropout
312
+ ehs = ehs.clone(); ehs[drop] = 0
313
+
314
+ # shifted flow-matching timesteps, full 0–1000 range (no masking)
315
+ sigmas = torch.rand(bsz, device=device)
316
+ sigmas = (config.shift * sigmas) / (1 + (config.shift - 1) * sigmas)
317
+ timesteps = sigmas * 1000
318
+ sigmas = sigmas[:, None, None, None]
319
+
320
+ noise = torch.randn_like(latents)
321
+ noisy = noise * sigmas + latents * (1 - sigmas)
322
+ target = noise - latents # velocity
323
+
324
+ pred = unet(noisy, timesteps, ehs, return_dict=False)[0]
325
+ loss = F.mse_loss(pred, target, reduction="none")
326
+ loss = loss.mean(dim=list(range(1, len(loss.shape))))
327
+
328
+ if log_to is not None:
329
+ for i in range(bsz):
330
+ log_to["train_step"].append(global_step)
331
+ log_to["train_loss"].append(loss[i].item())
332
+ log_to["train_timestep"].append(timesteps[i].item())
333
+ log_to["trained_images"].append(
334
+ {"step": global_step, "id": ids[i], "prompt": prompts[i]})
335
+ return loss.mean()
336
+
337
+ def plot_logs(d):
338
+ plt.figure(figsize=(10, 6))
339
+ plt.scatter(d["train_timestep"], d["train_loss"], s=3, c=d["train_step"],
340
+ marker=".", cmap="cool")
341
+ plt.xlabel("timestep"); plt.ylabel("loss"); plt.yscale("log")
342
+ plt.colorbar(label="step")
343
+
344
+ def save_checkpoint(step, epoch):
345
+ ckpt = os.path.join(real_output_dir, f"checkpoint-{step:08}")
346
+ os.makedirs(ckpt, exist_ok=True)
347
+ unet.save_pretrained(os.path.join(ckpt, "unet"), safe_serialization=True)
348
+ meta = {"step": step, "epoch": epoch, "run": RUN, "condition_column": col,
349
+ "trained_images": train_logs["trained_images"]}
350
+ meta_path = os.path.join(ckpt, "trained_images.json")
351
+ with open(meta_path, "w") as f:
352
+ json.dump(meta, f, indent=2)
353
+ if config.save_optimizer:
354
+ torch.save({"cfg": asdict(config), "student": unet.state_dict(),
355
+ "opt": optimizer.state_dict(), "scheduler": scheduler.state_dict(),
356
+ "gstep": step, "epoch": epoch},
357
+ os.path.join(ckpt, f"sd15_flow_lune_json_e{epoch}_s{step}.pt"))
358
+ print(f"βœ“ checkpoint {step} (epoch {epoch})")
359
+ if config.upload_to_hub and hf_api is not None:
360
+ try:
361
+ hf_api.upload_folder(folder_path=os.path.join(ckpt, "unet"),
362
+ path_in_repo=f"checkpoint-{step:08}/unet",
363
+ repo_id=config.hf_repo_id, repo_type="model")
364
+ hf_api.upload_file(path_or_fileobj=meta_path,
365
+ path_in_repo=f"checkpoint-{step:08}/trained_images.json",
366
+ repo_id=config.hf_repo_id, repo_type="model")
367
+ print(f" βœ“ uploaded to {config.hf_repo_id}")
368
+ except Exception as e:
369
+ print(f" ⚠ upload failed: {e}")
370
+
371
+ # ── training loop ────────────────────────────────────────────────────────
372
+ print(f"\nStarting training β€” RUN='{RUN}', conditioning on '{col}'\n")
373
+ progress = tqdm(total=total_steps)
374
+ for epoch in range(config.num_train_epochs):
375
+ for batch in train_loader:
376
+ if global_step >= total_steps:
377
+ break
378
+ loss = get_prediction(batch, log_to=train_logs)
379
+ t_writer.add_scalar("train/loss", loss.item(), global_step)
380
+ t_writer.add_scalar("train/lr", scheduler.get_last_lr()[0], global_step)
381
+
382
+ loss.backward()
383
+ gn = torch.nn.utils.clip_grad_norm_(unet.parameters(), 1.0)
384
+ t_writer.add_scalar("train/grad_norm", gn.item(), global_step)
385
+ optimizer.step(); scheduler.step(); optimizer.zero_grad()
386
+
387
+ progress.update(1)
388
+ progress.set_postfix({"epoch": epoch, "loss": f"{loss.item():.4f}",
389
+ "lr": f"{scheduler.get_last_lr()[0]:.2e}"})
390
+ global_step += 1
391
+
392
+ if global_step % 100 == 0:
393
+ plot_logs(train_logs)
394
+ t_writer.add_figure("train_loss", plt.gcf(), global_step)
395
+ plt.close()
396
+ if global_step % config.checkpointing_steps == 0:
397
+ save_checkpoint(global_step, epoch)
398
+
399
+ save_checkpoint(global_step, epoch) # end-of-epoch
400
+
401
+ progress.close()
402
+ print(f"\nβœ… Training complete β€” RUN='{RUN}'")
403
+ print(f" β†’ https://huggingface.co/{config.hf_repo_id}")
404
+ other = "vit" if RUN == "prompt" else "prompt"
405
+ print(f" Now set RUN = \"{other}\" and re-run this cell for the other half "
406
+ f"of the couple.")
407
+
408
+
409
+ # ═════════════════════════════════════════════════════════════════════════════
410
+ # 8. RUN
411
+ # ═════════════════════════════════════════════════════════════════════════════
412
+
413
+ train(TrainConfig())