Download kit/export_fme_latents.py from mahf708/aigs-hack-sep26-models: direct link, hf CLI and curl.
- Browser
- Download file 22.7 kB
-
https://huggingface.co/mahf708/aigs-hack-sep26-models/resolve/main/kit/export_fme_latents.py
- Command line
-
hf download hf://mahf708/aigs-hack-sep26-models/kit/export_fme_latents.py
-
curl -L -o export_fme_latents.py https://huggingface.co/mahf708/aigs-hack-sep26-models/resolve/main/kit/export_fme_latents.py
22.7 kB
| """Record an fme (ACE / Samudra) run's latents as an xaig latent archive. | |
| Runs ``fme.ace.inference`` (a YAML with ``initial_condition`` + ``forcing_loader``) or | |
| ``fme.ace.inference.evaluator`` (a YAML with ``loader``) with forward hooks on the | |
| network inside the stepper. | |
| - SFNO (atmosphere): layer 0 is the input to the first block (encoder output plus | |
| position embedding); layer L (1..num_layers) is the output of block L. | |
| - Samudra U-Net (ocean): only the full-resolution levels, which xaig can map -- | |
| layer 0 is the first encoder block's output, layer 1 the last decoder block's. | |
| Optionally *writes* a layer as well (steering): add ``alpha`` times a direction to | |
| layer L at every step (or a window of steps). The direction is one channel, or one | |
| feature of an xaig basis file (a Dictionary's unit direction scaled by its | |
| ``input_scale``, so ``alpha`` is in activation units; a PCA component in layer units). | |
| Network inputs are kept too, denormalized, exactly as the network saw them (so an | |
| intervention on CO2 or a frozen flux shows up in ``reference.nc``). | |
| Needs the model's environment (torch + fme) and xaig's archive writer, which is | |
| pure numpy: point ``--xaig-src`` at a checkout's ``src`` when xaig is not installed. | |
| python export_fme_latents.py atm-ctrl.yaml --out archives/atm-E05S01-ctrl --steps 20 | |
| The archive's ``experiment`` block records the checkpoint, the source config, the | |
| seed, the intervention (anything under ``--experiment``), the steering, and the | |
| recorded steps. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import datetime as dt | |
| import json | |
| import logging | |
| import shutil | |
| import sys | |
| from pathlib import Path | |
| import numpy as np | |
| import torch | |
| import yaml | |
| DEFAULT_FIELDS = [ | |
| "PS", "TS", "Tat2m", "Qat2m", "Uat10m", "Vat10m", | |
| "surface_precipitation_rate", "FLUT", "FSUTOA", "FLUS", "FLDS", "FSNS", | |
| "LHFLX", "SHFLX", "TAUX", "TAUY", "lwp", "lcc", | |
| "U_1", "V_1", "T_1", "U_3", "V_3", "U_5", "V_5", "T_5", "STW_5", "U_7", "V_7", "STW_7", | |
| ] # fmt: skip | |
| DEFAULT_INPUTS = [ | |
| "SOLIN", "global_mean_co2", "aerindexall", "colccn.3", "OCNFRAC", "ICEFRAC", "LANDFRAC", | |
| "PHIS", "TS", "FLUS", "FLDS", "FSNS", "LHFLX", "SHFLX", "TAUX", "TAUY", "sea_surface_fraction", | |
| ] # fmt: skip | |
| OCEAN_FIELDS = [ | |
| "sst", "ssh", "ocean_sea_ice_fraction", "iceVolumeTotal", "temperatureCoarsened_0", | |
| "temperatureCoarsened_5", "temperatureCoarsened_10", "salinityCoarsened_0", | |
| "velocityZonalCoarsened_0", "velocityMeridionalCoarsened_0", | |
| ] # fmt: skip | |
| def find_step_with_packer(stepper): | |
| """The step object that packs named inputs into the network's channels.""" | |
| candidates = [stepper, getattr(stepper, "_step_obj", None)] | |
| for obj in list(candidates): | |
| if obj is not None: | |
| candidates.extend(v for v in vars(obj).values() if hasattr(v, "in_packer")) | |
| for obj in candidates: | |
| if obj is not None and hasattr(obj, "in_packer") and hasattr(obj, "_normalizer"): | |
| return obj | |
| raise RuntimeError("could not find the step that packs the network inputs") | |
| class SkipRescale: | |
| """A pre-hook on an SFNO's decoder that rescales one input in the big-skip half only. | |
| The decoder reads ``cat(block output, normalized inputs)``; this changes channel | |
| ``embed_dim + index(name)`` from ``(x - mean) / std`` to ``(factor * x - mean) / std``, | |
| so the skip sees the input scaled while every block saw it as it was.""" | |
| def __init__(self, stepper, net, name, factor): | |
| if not getattr(net, "big_skip", False): | |
| raise SystemExit("--skip-rescale needs an SFNO with a big skip") | |
| step = find_step_with_packer(stepper) | |
| packed = list(step.in_packer.names) | |
| if name not in packed: | |
| raise SystemExit(f"{name} is not a network input; inputs are {packed}") | |
| self.channel = net.embed_dim + packed.index(name) | |
| self.mean = float(step._normalizer.means[name]) | |
| self.std = float(step._normalizer.stds[name]) | |
| self.factor = factor | |
| net.decoder.register_forward_pre_hook(self.hook) | |
| def hook(self, module, args): | |
| x = args[0].clone() | |
| physical = x[:, self.channel] * self.std + self.mean | |
| x[:, self.channel] = (self.factor * physical - self.mean) / self.std | |
| return (x, *args[1:]) | |
| class InputCapture: | |
| """A pre-hook on the network that keeps chosen inputs, denormalized, at recorded steps.""" | |
| def __init__(self, stepper, net_outer, names, recorder, n_field_times): | |
| step = find_step_with_packer(stepper) | |
| packed = list(step.in_packer.names) | |
| missing = [n for n in names if n not in packed] | |
| if missing: | |
| logging.warning("inputs not among the network's inputs, skipped: %s", missing) | |
| self.names = [n for n in names if n in packed] | |
| self.index = {n: packed.index(n) for n in self.names} | |
| self.mean = {n: float(step._normalizer.means[n]) for n in self.names} | |
| self.std = {n: float(step._normalizer.stds[n]) for n in self.names} | |
| self.rec = recorder | |
| self.n_field_times = n_field_times | |
| self.values = {} | |
| net_outer.register_forward_pre_hook(self.hook) # after the recorder's counter | |
| def hook(self, module, args): | |
| k = self.rec.call | |
| x = args[0] | |
| if k >= self.n_field_times or x.shape[0] != 1: | |
| return | |
| for n in self.names: | |
| v = x[0, self.index[n]].detach().float() * self.std[n] + self.mean[n] | |
| v = v.reshape(-1).cpu().numpy() | |
| if n not in self.values: | |
| self.values[n] = np.full((self.n_field_times, v.size), np.nan, dtype=np.float32) | |
| self.values[n][k] = v | |
| def parse_args(argv=None): | |
| p = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) | |
| p.add_argument("config", help="fme inference YAML (initial_condition + forcing_loader)") | |
| p.add_argument("--out", required=True, help="archive directory to write") | |
| p.add_argument("--steps", type=int, required=True, help="forward steps to run") | |
| p.add_argument("--every", type=int, default=1, help="record every Nth step") | |
| p.add_argument("--layers", default="all", help="'all' or comma list, e.g. 0,4,8") | |
| p.add_argument( | |
| "--fields", | |
| default=None, | |
| help="output variables kept in reference.nc (default: a set per component)", | |
| ) | |
| p.add_argument( | |
| "--inputs", | |
| default=",".join(DEFAULT_INPUTS), | |
| help="network inputs kept in reference.nc, as the network saw them " | |
| "(interventions included); named <name>_input when an output shares the name", | |
| ) | |
| p.add_argument("--dtype", default="float16") | |
| p.add_argument( | |
| "--seed", | |
| type=int, | |
| default=0, | |
| help="rollout seed; the noise-conditioned SFNO is stochastic, so runs meant " | |
| "to be compared node by node must share it", | |
| ) | |
| p.add_argument("--steer", help="L:BASIS.npz:FEATURE:ALPHA add alpha x a basis direction") | |
| p.add_argument("--steer-channel", help="L:CHANNEL:AMOUNT add a constant to one channel") | |
| p.add_argument( | |
| "--steer-vector", help="L:VECTOR.npy:ALPHA add alpha x a saved (channels,) vector" | |
| ) | |
| p.add_argument("--steer-window", default=None, help="FIRST:LAST forward steps to steer") | |
| p.add_argument( | |
| "--skip-rescale", | |
| help="NAME:FACTOR SFNO with a big skip only: scale one input, in physical units, in the " | |
| "copy of the inputs the skip hands the decoder, leaving what the blocks see alone", | |
| ) | |
| p.add_argument("--model-name", default=None, help="manifest 'model' (default: run id)") | |
| p.add_argument("--experiment", default="{}", help="JSON merged into the manifest's experiment") | |
| p.add_argument("--xaig-src", default=None, help="path to an xaig checkout's src/") | |
| p.add_argument("--keep-run", action="store_true", help="keep the fme experiment_dir") | |
| return p.parse_args(argv) | |
| def find_network(stepper): | |
| """(kind, outermost module, network, taps): taps are (module, 'pre'|'post', label).""" | |
| from fme.core.models.conditional_sfno.sfnonet import SphericalFourierNeuralOperatorNet | |
| modules = list(stepper.modules.modules()) | |
| sfno = [m for m in modules if isinstance(m, SphericalFourierNeuralOperatorNet)] | |
| if len(sfno) == 1: | |
| net = sfno[0] | |
| from fme.ace.registry.stochastic_sfno import NoiseConditionedModel | |
| outer = [m for m in modules if isinstance(m, NoiseConditionedModel)] | |
| taps = [(net.blocks[0], "pre", "encoder output (input to block 1)")] | |
| taps += [(b, "post", f"SFNO block {i} output") for i, b in enumerate(net.blocks, 1)] | |
| return "sfno", (outer[0] if outer else net), net, taps | |
| from fme.ace.models.ocean.m2lines.samudra import Samudra | |
| samudra = [m for m in modules if isinstance(m, Samudra)] | |
| if len(samudra) == 1: | |
| net = samudra[0] | |
| taps = [ | |
| ( | |
| net.layers[0], | |
| "post", | |
| f"encoder level 1 output ({net.ch_width[0]} ch, full resolution)", | |
| ), | |
| ( | |
| net.layers[-2], | |
| "post", | |
| f"last decoder block output ({net.ch_width[0]} ch, full resolution)", | |
| ), | |
| ] | |
| return "samudra", net, net, taps | |
| raise RuntimeError("found neither one SFNO nor one Samudra in the stepper") | |
| class Recorder: | |
| """Forward hooks that record chosen layers at chosen steps and optionally steer one.""" | |
| def __init__( | |
| self, outer, taps, layers, steps, every, staging: Path, dtype, steer=None, window=None | |
| ): | |
| self.outer = outer | |
| self.taps = taps | |
| self.n_layers = len(taps) | |
| self.layers = ( | |
| list(range(self.n_layers)) if layers == "all" else [int(x) for x in layers.split(",")] | |
| ) | |
| for layer in self.layers: | |
| if not 0 <= layer < self.n_layers: | |
| raise ValueError(f"layer {layer} not in 0..{self.n_layers - 1}") | |
| self.recorded_steps = list(range(0, steps, every)) | |
| self.slot = {s: i for i, s in enumerate(self.recorded_steps)} | |
| self.staging = staging | |
| self.dtype = dtype | |
| self.steer = steer # (layer, torch vector (C,)) | |
| self.window = window | |
| self.arrays = {} | |
| self.call = -1 | |
| self.shape = None | |
| def _array(self, layer, x): | |
| if layer not in self.arrays: | |
| n_ch, h, w = x.shape[1:] | |
| self.shape = (h, w) | |
| path = self.staging / f"raw_{layer:02d}.npy" | |
| self.arrays[layer] = np.lib.format.open_memmap( | |
| path, mode="w+", dtype=self.dtype, shape=(len(self.recorded_steps), h * w, n_ch) | |
| ) | |
| return self.arrays[layer] | |
| def _steer(self, layer, x): | |
| if self.steer is None or self.steer[0] != layer: | |
| return x | |
| if self.window is not None and not (self.window[0] <= self.call <= self.window[1]): | |
| return x | |
| vec = self.steer[1].to(device=x.device, dtype=x.dtype) | |
| if vec.numel() < x.shape[1]: # a one-channel push, given only up to its channel | |
| vec = torch.nn.functional.pad(vec, (0, x.shape[1] - vec.numel())) | |
| if vec.numel() != x.shape[1]: | |
| raise RuntimeError( | |
| f"steering direction has {vec.numel()} channels, layer {layer} has {x.shape[1]}" | |
| ) | |
| return x + vec.view(1, -1, 1, 1) | |
| def _record(self, layer, x): | |
| if layer in self.layers and self.call in self.slot: | |
| if x.shape[0] != 1: | |
| raise RuntimeError(f"recording needs batch 1, got {x.shape[0]}") | |
| flat = x[0].detach().float().reshape(x.shape[1], -1).T # (nodes, channels), lat-major | |
| self._array(layer, x)[self.slot[self.call]] = flat.cpu().numpy().astype(self.dtype) | |
| def attach(self): | |
| def count(module, args): | |
| self.call += 1 | |
| self.outer.register_forward_pre_hook(count, prepend=True) | |
| for layer, (module, kind, _) in enumerate(self.taps): | |
| if kind == "pre": | |
| def pre(module, args, layer=layer): | |
| x = self._steer(layer, args[0]) | |
| self._record(layer, x) | |
| return (x, *args[1:]) | |
| module.register_forward_pre_hook(pre) | |
| else: | |
| def post(module, args, out, layer=layer): | |
| out = self._steer(layer, out) | |
| self._record(layer, out) | |
| return out | |
| module.register_forward_hook(post) | |
| def labels(self): | |
| return [label for _, _, label in self.taps] | |
| def steering_vector(args): | |
| import torch | |
| if sum(bool(x) for x in (args.steer, args.steer_channel, args.steer_vector)) > 1: | |
| raise SystemExit("give one of --steer, --steer-channel, --steer-vector") | |
| if args.steer_vector: | |
| layer, path, alpha = args.steer_vector.split(":") | |
| vec = float(alpha) * np.load(path).astype(np.float64).ravel() | |
| info = {"layer": int(layer), "vector": Path(path).name, "by": float(alpha)} | |
| return int(layer), torch.as_tensor(vec, dtype=torch.float32), info | |
| if args.steer_channel: | |
| layer, ch, amount = args.steer_channel.split(":") | |
| vec = torch.zeros(int(ch) + 1) # padded to the layer's width at first use | |
| vec[int(ch)] = float(amount) | |
| return int(layer), vec, {"layer": int(layer), "channel": int(ch), "by": float(amount)} | |
| if args.steer: | |
| from xaig.daig.latent import load_basis | |
| layer, path, feature, alpha = args.steer.split(":") | |
| basis = load_basis(path) | |
| feature, alpha = int(feature), float(alpha) | |
| # a Dictionary's activation a adds a * input_scale of its unit direction to the | |
| # layer; a PCA component is a unit direction in the layer's own units | |
| scale = float(getattr(basis, "input_scale", 1.0)) | |
| vec = alpha * scale * np.asarray(basis.directions()[feature], dtype=np.float64) | |
| info = { | |
| "layer": int(layer), | |
| "basis": Path(path).name, | |
| "kind": basis.kind, | |
| "feature": feature, | |
| "by": alpha, | |
| "input_scale": scale, | |
| } | |
| return int(layer), torch.as_tensor(vec, dtype=torch.float32), info | |
| return None, None, None | |
| def time_labels(ds): | |
| """Valid-time labels (ISO, 4-digit year) of the prediction file, initial condition first.""" | |
| t = ds["valid_time"].values if "valid_time" in ds else ds["time"].values | |
| t = np.asarray(t).reshape(-1) | |
| return [x.strftime("%Y-%m-%dT%H:%M:%S") if hasattr(x, "strftime") else str(x)[:19] for x in t] | |
| def main(argv=None): | |
| args = parse_args(argv) | |
| if args.xaig_src: | |
| sys.path.insert(0, args.xaig_src) | |
| import fme.ace.inference.evaluator as evaluator | |
| import fme.ace.inference.inference as inference | |
| import xarray as xr | |
| from xaig.adapters.latent_archive import write_archive | |
| from xaig.daig.grid import Grid | |
| out = Path(args.out).resolve() | |
| staging = out.parent / f".{out.name}.work" | |
| if staging.exists(): | |
| shutil.rmtree(staging) | |
| staging.mkdir(parents=True) | |
| run_dir = staging / "fme" | |
| cfg = yaml.safe_load(Path(args.config).read_text()) | |
| source_experiment_dir = cfg.get("experiment_dir") | |
| cfg["experiment_dir"] = str(run_dir) | |
| cfg["n_forward_steps"] = args.steps | |
| cfg["seed"] = args.seed | |
| cfg["forward_steps_in_memory"] = min(cfg.get("forward_steps_in_memory", 20), args.steps) | |
| evaluating = "loader" in cfg | |
| fields = None if args.fields is None else [f for f in args.fields.split(",") if f] | |
| if fields is None: | |
| import torch as _torch | |
| ckpt_cfg = _torch.load(cfg["checkpoint_path"], map_location="cpu", weights_only=False) | |
| out_names = ckpt_cfg["stepper"]["config"]["step"]["config"].get("out_names", []) | |
| del ckpt_cfg | |
| fields = OCEAN_FIELDS if "sst" in out_names else DEFAULT_FIELDS | |
| cfg["data_writer"] = { | |
| "save_prediction_files": bool(fields), | |
| "save_monthly_files": False, | |
| **({"names": fields} if fields else {}), | |
| } | |
| cfg.setdefault("logging", {})["log_to_wandb"] = False | |
| run_yaml = staging / "run.yaml" | |
| run_yaml.write_text(yaml.safe_dump(cfg, sort_keys=False)) | |
| state = {} | |
| config_class = evaluator.InferenceEvaluatorConfig if evaluating else inference.InferenceConfig | |
| original_load = config_class.load_stepper | |
| def load_stepper_with_hooks(self): | |
| stepper = original_load(self) | |
| kind, outer, net, taps = find_network(stepper) | |
| s_layer, s_vec, s_info = steering_vector(args) | |
| window = tuple(int(v) for v in args.steer_window.split(":")) if args.steer_window else None | |
| rec = Recorder( | |
| outer, taps, args.layers, args.steps, args.every, staging, args.dtype, | |
| steer=(s_layer, s_vec) if s_vec is not None else None, window=window, | |
| ) # fmt: skip | |
| rec.attach() | |
| inputs = [n for n in args.inputs.split(",") if n] | |
| if inputs: | |
| state["inputs"] = InputCapture(stepper, outer, inputs, rec, args.steps + 1) | |
| if args.skip_rescale: | |
| if kind != "sfno": | |
| raise SystemExit("--skip-rescale applies to an SFNO only") | |
| name, factor = args.skip_rescale.rsplit(":", 1) | |
| SkipRescale(stepper, net, name, float(factor)) | |
| state["skip"] = {"input": name, "factor": float(factor)} | |
| state.update(rec=rec, steer=s_info, window=window, kind=kind) | |
| return stepper | |
| config_class.load_stepper = load_stepper_with_hooks | |
| try: | |
| (evaluator if evaluating else inference).main(str(run_yaml)) | |
| finally: | |
| config_class.load_stepper = original_load | |
| rec: Recorder = state["rec"] | |
| if rec.call + 1 != args.steps: | |
| logging.warning("model was called %d times for %d steps", rec.call + 1, args.steps) | |
| pred = ( | |
| xr.open_dataset(run_dir / "autoregressive_predictions.nc", decode_timedelta=False) | |
| if fields | |
| else None | |
| ) | |
| ic = xr.open_dataset(run_dir / "initial_condition.nc") if fields else None | |
| if pred is None: | |
| raise SystemExit( | |
| "--fields '' is not supported yet: the grid and times come from the prediction file" | |
| ) | |
| lat1 = pred["lat"].values | |
| lon1 = pred["lon"].values | |
| lon, lat = np.meshgrid(lon1, lat1) | |
| ocean = state.get("kind") == "samudra" | |
| mask = None | |
| if ocean and "sst" in pred: # land holds no ocean: the first predicted SST says where | |
| mask = np.isfinite(pred["sst"].values.reshape(-1, lat1.size * lon1.size)[0]) | |
| grid = Grid(lat=lat.ravel(), lon=lon.ravel(), shape=(lat1.size, lon1.size), mask=mask) | |
| if rec.shape != (lat1.size, lon1.size): | |
| raise SystemExit( | |
| f"latent grid {rec.shape} differs from the output grid {(lat1.size, lon1.size)}" | |
| ) | |
| all_times = time_labels(pred) # prediction valid times, one per step | |
| ic_time = time_labels(ic)[0] if "valid_time" in ic or "time" in ic else None | |
| if len(all_times) == args.steps and ic_time is not None: | |
| field_times = [ic_time, *all_times] | |
| else: | |
| field_times = all_times | |
| step_input_times = field_times[: args.steps] # the state each forward call started from | |
| times = [step_input_times[s] for s in rec.recorded_steps] | |
| field_arrays = {} | |
| for name in fields: | |
| if name not in pred: | |
| logging.warning("field %s not in predictions; skipped", name) | |
| continue | |
| values = pred[name].values.reshape(-1, lat1.size * lon1.size) # (steps, nodes) | |
| if len(field_times) == values.shape[0] + 1: | |
| first = ( | |
| ic[name].values.reshape(1, -1) | |
| if name in ic | |
| else np.full((1, values.shape[1]), np.nan) | |
| ) | |
| values = np.concatenate([first, values]) | |
| field_arrays[name] = values.astype(np.float32) | |
| captured = state.get("inputs") | |
| if captured is not None: | |
| if len(field_times) != args.steps + 1: | |
| raise SystemExit( | |
| "input capture needs the initial condition's time in the prediction files" | |
| ) | |
| for name, values in captured.values.items(): | |
| key = f"{name}_input" if name in field_arrays else name | |
| field_arrays[key] = values | |
| ckpt = cfg["checkpoint_path"] | |
| run_id = Path(ckpt).parent.parent.name | |
| experiment = { | |
| "run_id": run_id, | |
| "source_config": str(Path(args.config).resolve()), | |
| "source_experiment_dir": source_experiment_dir, | |
| "initial_condition": cfg.get("initial_condition", {}).get("path"), | |
| "seed": cfg.get("seed"), | |
| "recorded_steps": {"n": args.steps, "every": args.every}, | |
| "exported": dt.datetime.now().isoformat(timespec="seconds"), | |
| **json.loads(args.experiment), | |
| } | |
| if state.get("skip"): | |
| experiment["skip_rescale"] = state["skip"] | |
| if state.get("steer"): | |
| experiment["steer"] = { | |
| **state["steer"], | |
| **({"window": list(state["window"])} if state["window"] else {}), | |
| } | |
| labels = rec.labels() | |
| layers = [(labels[i], rec.arrays[i]) for i in sorted(rec.arrays)] | |
| network_layers = None | |
| if sorted(rec.arrays) != list(range(len(layers))): | |
| # the reader indexes layers by position; record where each sits in the network, so | |
| # that a basis is matched by that place (and keep it in the label, for people) | |
| layers = [(f"{labels[i]} [network layer {i}]", rec.arrays[i]) for i in sorted(rec.arrays)] | |
| network_layers = sorted(rec.arrays) | |
| step_seconds = ( | |
| int( | |
| round( | |
| (np.datetime64(field_times[1]) - np.datetime64(field_times[0])) | |
| / np.timedelta64(1, "s") | |
| ) | |
| ) | |
| if len(field_times) > 1 | |
| else None | |
| ) | |
| write_archive( | |
| out, | |
| grid=grid, | |
| times=times, | |
| layers=layers, | |
| network_layers=network_layers, | |
| fields=field_arrays or None, | |
| field_times=field_times if field_arrays else None, | |
| model=args.model_name or run_id, | |
| component="ocean" if ocean else "atmosphere", | |
| checkpoint=ckpt, | |
| calendar=str(pred["time"].attrs.get("calendar", "noleap")) if "time" in pred else "noleap", | |
| timestep_seconds=step_seconds, | |
| experiment=experiment, | |
| dtype=args.dtype, | |
| overwrite=True, | |
| ) | |
| if args.keep_run: | |
| shutil.move(str(run_dir), str(out / "_fme_run")) | |
| shutil.rmtree(staging, ignore_errors=True) | |
| print( | |
| f"wrote {out}: {len(times)} times x {len(layers)} layers; fields: {', '.join(field_arrays)}" | |
| ) | |
| if __name__ == "__main__": | |
| main() | |