File size: 8,948 Bytes
e793773
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
"""Public RDPO high-noise fine-tuning entry for SCoPE.

Training reproduces the released recipe: only the high-noise expert is
optimized, timesteps are sampled from ``[0.9, 1.0)``, and the data mixture is a
``ConcatDataset`` of the four native loaders in :mod:`scope.data`. The run is
described by a single YAML config (see configs/train_rdpo_high_only.yaml).
"""

from __future__ import annotations

import argparse
from pathlib import Path
from typing import Any

import pytorch_lightning as pl
import torch
import yaml
from pytorch_lightning.callbacks import ModelCheckpoint
from torch.utils.data import DataLoader
from torch.utils.data.dataloader import default_collate

from scope.config import SCOPE_MODEL_ID, InferenceConfig
from scope.data import build_training_dataset
from scope.patch import enable_scope_grad
from scope.weights import load_pipeline, resolve_model_dir

_TRAINABLE_KEYWORDS = ["plucker_pe", "self_attn", "norm3", "ffn"]

# PIL first frames cannot be default-collated, so they are kept as a list.
_LIST_KEYS = ("first_frame_pil",)


def load_config(path: Path) -> dict[str, Any]:
    config = yaml.safe_load(Path(path).read_text(encoding="utf-8"))
    if not isinstance(config, dict):
        raise ValueError(f"Training config must be a mapping: {path}")
    return config


def _collate(batch: list[dict[str, Any]]) -> dict[str, Any]:
    output: dict[str, Any] = {}
    for key in batch[0]:
        values = [sample[key] for sample in batch]
        output[key] = values if key in _LIST_KEYS else default_collate(values)
    return output


class SCoPEFineTuner(pl.LightningModule):
    """Fine-tune the released SCoPE model in the RDPO high-noise regime."""

    def __init__(
        self,
        model_path: str,
        learning_rate: float,
        weight_decay: float,
        height: int,
        width: int,
        num_frames: int,
    ) -> None:
        super().__init__()
        self.save_hyperparameters()
        self.pipe = None

    def setup(self, stage: str | None = None) -> None:
        if self.pipe is not None:
            return
        inference_config = InferenceConfig(
            height=self.hparams.height,
            width=self.hparams.width,
            num_frames=self.hparams.num_frames,
        )
        model_dir = resolve_model_dir(self.hparams.model_path)
        self.pipe = load_pipeline(model_dir, inference_config)
        self.pipe.i2v_vae_condition_mode = "official_zero"
        enable_scope_grad(self.pipe, _TRAINABLE_KEYWORDS, expert="high_noise_model")
        object.__setattr__(self, "dit", self.pipe.dit)
        object.__setattr__(self, "dit2", self.pipe.dit2)
        self.pipe.scheduler.set_timesteps(
            self.pipe.scheduler.num_train_timesteps,
            training=True,
        )

    def training_step(self, batch: dict[str, Any], batch_index: int) -> torch.Tensor:
        del batch_index
        if len(batch["first_frame_pil"]) != 1:
            raise ValueError("SCoPE A14B training currently requires batch_size=1 per GPU")
        self.pipe.device = self.device

        self.pipe.load_models_to_device(["vae"])
        video = batch["video"].to(device=self.device, dtype=self.pipe.torch_dtype)
        with torch.inference_mode():
            latents = self.pipe.vae.single_encode(video, self.device)
        latents = latents.to(device=self.device, dtype=self.pipe.torch_dtype).detach()

        self.pipe.load_models_to_device(["text_encoder"])
        with torch.inference_mode():
            context = self.pipe.prompter.encode_prompt(
                batch["caption"], positive=True, device=self.device
            )

        conditioning = self.pipe.build_i2v_conditioning(
            input_image=batch["first_frame_pil"][0],
            num_frames=self.hparams.num_frames,
            height=self.hparams.height,
            width=self.hparams.width,
        )
        if conditioning is None:
            raise RuntimeError("The selected model does not expose Wan2.2 I2V conditioning")

        camera = {
            "pose": batch["pose"].to(device=self.device, dtype=self.pipe.torch_dtype),
            "x_fov": batch["x_fov"].to(device=self.device, dtype=self.pipe.torch_dtype),
            "xi": batch["xi"].to(device=self.device, dtype=self.pipe.torch_dtype),
        }
        self.pipe.load_models_to_device(["dit", "dit2"])
        loss = self.pipe.training_loss(
            input_latents=latents,
            noise=torch.randn_like(latents),
            context=context,
            height=self.hparams.height,
            width=self.hparams.width,
            camera_control_panshot=camera,
            y=conditioning,
            first_frame_latents=conditioning[:, 4:, 0:1].clone(),
            min_timestep_boundary=0.9,
            max_timestep_boundary=1.0,
            switch_DiT_boundary=0.9,
            use_gradient_checkpointing=True,
            use_gradient_checkpointing_offload=False,
        )
        self.log("train_loss", loss, on_step=True, on_epoch=True, prog_bar=True, sync_dist=True)
        return loss

    def configure_optimizers(self) -> torch.optim.Optimizer:
        parameters = [
            parameter for parameter in self.pipe.dit.parameters() if parameter.requires_grad
        ]
        if not parameters:
            raise RuntimeError("No trainable parameters found in the high-noise expert")
        return torch.optim.AdamW(
            parameters,
            lr=self.hparams.learning_rate,
            weight_decay=self.hparams.weight_decay,
        )


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument(
        "--config",
        type=Path,
        default=Path("configs/train_rdpo_high_only.yaml"),
        help="Training YAML (see configs/train_rdpo_high_only.yaml).",
    )
    parser.add_argument("--model_path", default=None, help="Override config model_path.")
    parser.add_argument("--output_dir", type=Path, default=None, help="Override config output_dir.")
    parser.add_argument("--num_gpus", type=int, default=None, help="Override trainer.num_gpus.")
    parser.add_argument("--max_steps", type=int, default=None, help="Override trainer.max_steps.")
    parser.add_argument("--resume_from_checkpoint", type=Path, default=None)
    return parser


def build_strategy(num_gpus: int) -> "str | Any":
    if num_gpus <= 1:
        return "auto"
    from pytorch_lightning.strategies import FSDPStrategy
    from torch.distributed.fsdp import ShardingStrategy

    from diffsynth.models.wan_video_dit import DiTBlock

    return FSDPStrategy(
        sharding_strategy=ShardingStrategy.FULL_SHARD,
        auto_wrap_policy={DiTBlock},
        state_dict_type="sharded",
        use_orig_params=True,
    )


def main() -> None:
    args = build_parser().parse_args()
    config = load_config(args.config)

    data_config = config["data"]
    trainer_config = config.get("trainer", {})
    optimizer_config = config.get("optimizer", {})

    model_path = args.model_path or config.get("model_path", SCOPE_MODEL_ID)
    output_dir = Path(args.output_dir or config.get("output_dir", "outputs/training"))
    num_gpus = args.num_gpus or int(trainer_config.get("num_gpus", 8))
    max_steps = args.max_steps or int(trainer_config.get("max_steps", 10_000))

    pl.seed_everything(int(config.get("seed", 42)), workers=True)

    dataset = build_training_dataset(data_config)
    num_workers = int(data_config.get("num_workers", 4))
    loader = DataLoader(
        dataset,
        batch_size=1,
        shuffle=True,
        num_workers=num_workers,
        collate_fn=_collate,
        pin_memory=True,
        persistent_workers=num_workers > 0,
    )

    checkpoint = ModelCheckpoint(
        dirpath=output_dir / "checkpoints",
        filename="scope-{step:06d}",
        every_n_train_steps=int(trainer_config.get("save_every_n_steps", 1_000)),
        save_top_k=-1,
        save_last=True,
        save_on_train_epoch_end=False,
    )
    model = SCoPEFineTuner(
        model_path=model_path,
        learning_rate=float(optimizer_config.get("learning_rate", 2e-5)),
        weight_decay=float(optimizer_config.get("weight_decay", 1e-2)),
        height=int(data_config.get("height", 480)),
        width=int(data_config.get("width", 832)),
        num_frames=int(data_config.get("num_frames", 81)),
    )
    trainer = pl.Trainer(
        accelerator="gpu",
        devices=num_gpus,
        strategy=build_strategy(num_gpus),
        precision=str(trainer_config.get("precision", "bf16-mixed")),
        max_steps=max_steps,
        gradient_clip_val=float(trainer_config.get("gradient_clip_val", 1.0)),
        default_root_dir=output_dir,
        callbacks=[checkpoint],
        log_every_n_steps=int(trainer_config.get("log_every_n_steps", 10)),
    )
    trainer.fit(model, train_dataloaders=loader, ckpt_path=args.resume_from_checkpoint)


if __name__ == "__main__":
    main()