fix(video): declared-shape predict for 20 video models and videomae pretraining fidelity - #2180
Conversation
OpticalFlowBase.PredictCore rejected every rank-3 input ("Input must be
rank 4"). Thirteen flow models (DKM, DPFlow, FlowDiffuser, FlowFormer++,
MemFlow, NeuFlowV2, RoMa, RPKNet, SEA-RAFT, SKFlow, UFM, UniMatch,
VideoFlow) declare the stacked pair as InputType.ThreeDimensional with
InputDepth 6, so GetInputShape() is [6, H, W] and none of them could
predict on their own declared shape. RAPIDFlow hit the same check with
an unbatched pair.
PredictCore now promotes a rank-3 [2*C, H, W] pair with the shared
PromoteToBatchedTensor helper and squeezes the unit batch back off the
flow with a recorded reshape (unbatched in, unbatched out, as in the base
PredictCore). The base's input and output [TensorLayout]s are marked
BatchOptional and OutputAxesFor answers rank 3, so the declared contract
matches. RAFT overrides PredictCore and inherits that layout, so it gets
the same promotion; it used to index Shape[3] and throw IndexOutOfRange.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…clip softmax TimeSformer's constructor took the clip length from numFrames (default 8) and ignored Architecture.InputFrames, so a 16-frame architecture built an 8-frame model: NumFrames and metadata said 8, the positional table was sized for 8 frames, and the divided space-time blocks' plain Forward grouped tokens by 8. It now applies VideoMAE's rule: the architecture's count wins and a conflicting explicit numFrames throws. The rule moves to an internal VideoClipFrameCount helper that VideoMAE and TimeSformer share (VideoMAE's behaviour is unchanged). Two convention bugs in Classify: - An unbatched [T, C, H, W] clip returned [1, NumClasses]. Classify's own docs, the batch-optional output layout and the base PredictCore all say [NumClasses]; it now squeezes the batch axis the tokenizer added. - The softmax normalised over the whole [B, NumClasses] tensor, so a batch's probabilities summed to 1 in total rather than per clip. It now uses Engine.Softmax over the class axis. The native constructor's XML docs were attached to the parameterless constructor; they are moved to the constructor they describe. Default parameter count is unchanged (113,541,120). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e videoclip's clip Each failed Predict on a tensor of its own declared input shape: - BasicVSR++ declares a single [3, H, W] frame and read it as a clip of three 2-D frames. EnhanceVideo now treats [C, H, W] as a one-frame clip (the super-resolution family's degenerate case) and returns [C, sH, sW]. - OpenSora declares a single [3, H, W] latent; PredictCore indexed axis 3 of its features. It now denoises it as a batch of one. - ProPainter declares a single [3, H, W] frame; the image path indexed axis 3. PredictCore now runs it as a one-frame clip. - VideoCLIP's input layout is [Frames, C, H, W] and it owns a clip length (numFrames, default 32), but its default architecture declared one [3, 224, 224] frame. It now declares FourDimensional with inputFrames 32 and takes numFrames from the architecture when declared (VideoClipFrameCount). Its layer stack runs per frame, so the lazy-shape walk now starts from one frame; otherwise the rank-5 clip left the stack unresolved and the default model reported 38.0M parameters instead of 211.1M at construction (this also affected any VideoCLIP built on a FourDimensional architecture before this change). RAFT, GMFlow, RAPIDFlow and RIFE are not changed here: their architectures document InputDepth as the per-frame channel count, so their declared shape is one frame while Predict needs a pair. Fixing that changes the meaning of a public constructor argument and needs a decision. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ise patch targets Four fidelity gaps in PretrainMAE against Tong et al. 2022: - It computed a reconstruction loss and returned it without back-propagating or updating a weight. It now records the masked forward and the loss on a GradientTape and steps the optimizer through BackwardAndStepOnPrecomputedLoss, the library's caller-owned-tape training path (NeRF, TVAE, TabDDPM, the GANs), using the constructor's optimizer or the default Adam. The loss is built from engine ops so it back-propagates; ComputeReconstructionLoss returns its value. - Masked patches were zeroed once, in place, at the embedding. The 3x3 encoder blocks then refilled them from visible neighbours and the bias, and the in-place write was invisible to the tape, so gradient still reached the patch embedding through masked patches' own pixels. The convolutional encoder cannot drop grid positions, so it is made sparse as in SparK / ConvNeXt V2: a recorded visibility mask is applied to the embedding and after every encoder block. - The decoder stacked a Tensor.Transform GELU after each ReLU conv block; Transform records no autodiff node, so the loss reached the head and nothing before it. Removed (the encoder had the same fix earlier). - The target was raw pixels. VideoMAEOptions.NormalizeTarget (default true, the paper's normlize_target) normalises each channel of each tubelet patch by its own mean and unbiased std (+1e-6). No parameters are added (default count unchanged, 65,788,816); the paper's learned mask token is not added, so masked positions enter the decoder as zeros. Clone and serialize keep NormalizeTarget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. 🗂️ Base branches to auto review (2)
Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
a03f6e1
into
fix/videomae-pretraining-and-timesformer
Summary
The follow-up work PR #2174 listed as "what this does not do". Four commits, all root-cause fixes in the library, each with a regression test that was proven to fail against the unfixed source (method below).
This PR is based on
fix/videomae-pretraining-and-timesformer(PR #2174), which is still open at4dedc7599. It is not based onmaster; merge #2174 first, or retarget this once #2174 lands. Every line number below is in the unfixed file at that base commit.Four groups of defects:
Predicton their own declared input shape — their shared base rejected every unbatched input.Architecture.InputFrames, and had two output-convention bugs (one of them a real numerical bug: a batch softmax).1. Optical flow:
Predicton the declared shapeRoot cause:
src/Video/OpticalFlowBase.cs:331threw"Input must be rank 4 [batch, 2*channels, height, width], got rank 3."for any rank-3 input. Thirteen models declare their architecture asInputType.ThreeDimensionalwithInputDepth = 6(a frame PAIR stacked on the channel axis), soGetInputShape()is the unbatched[6, H, W]— the shape the model advertises is exactly the shape itsPredictrefused.Affected: DKM, DPFlow, FlowDiffuser, FlowFormer++, MemFlow, NeuFlowV2, RoMa, RPKNet, SEA-RAFT, SKFlow, UFM, UniMatch, VideoFlow. RAPIDFlow hit the same check with an unbatched pair.
Fix (
PredictCore): a rank-3[2*C, H, W]pair is promoted with the established shared helperNeuralNetworkBase.PromoteToBatchedTensor, and the unit batch axis is squeezed back off the flow with a recordedEngine.Reshape— unbatched in, unbatched out, the same rule the basePredictCoreapplies to every model that inherits it (NeuralNetworkBase.cs:6385promote /:6434squeeze). The rank-4 body moved into a privatePredictBatchedPair, so the batched path is byte-for-byte the same computation.The declaration was brought in line with the code: both family
[TensorLayout]s are nowBatchOptional = true, andOutputAxesForanswers rank 3 ([Channels=Fixed(2), Height=Same, Width=Same]). Without that, the base would have accepted a rank the type does not declare.RAFT (
src/Video/Motion/RAFT.cs:267) overridesPredictCorebut inherits that layout, so it owes the same contract: it slicedinput.Shape[3]unconditionally and threwIndexOutOfRangeExceptionon an unbatched pair. It now promotes/squeezes the same way, and a non-3/4 rank gets a clearArgumentExceptioninstead of an index crash.Predict(GetInputShape())ArgumentException: Input must be rank 4 …[2, H, W], equal element-for-element to the batched[1, 2, H, W][6, H, W]ArgumentException[2, H, W][6, H, W]IndexOutOfRangeException[2, H, W]2. The seven model-specific declared-shape failures
src/Video/Enhancement/BasicVSRPlusPlus.cs:542read[C,H,W]as a clip ofCtwo-dimensional frames, so the feature extractor got a rank-2 "frame" (ConvolutionalLayer expects rank-3 … got rank 2)EnhanceVideotreats[C,H,W]as the one-frame clip the super-resolution family layout already documents ("A single low-resolution frame - the degenerate one-frame clip"), runs it as[1,C,H,W]and returns[C, sH, sW]. Recorded reshapes, so training through a single frame keeps its gradient pathsrc/Video/Generation/OpenSora.cs:350; the denoiser indexes axis 3 of its features, so a rank-3 latent threwIndexOutOfRangeExceptionsrc/Video/Inpainting/ProPainter.cs:270; the image path indexes axis 3,IndexOutOfRangeExceptionsrc/Video/Understanding/VideoCLIP.cs:187declared one[3,224,224]frame although the model's input layout is[Frames, C, H, W]and it owns a clip length (numFrames, default 32);EncodeVideo→AddBatchDimension5DthrewIndexOutOfRangeExceptionFourDimensionalwithinputFrames: 32;numFrames(:238) is now resolved from the architecture when it declares one, with a conflicting explicit value rejected — the same rule VideoMAE and TimeSformer use⚠ DECISION NEEDED: four models whose declared shape cannot be fixed without a contract change
RAFT, GMFlow, RAPIDFlow and RIFE still fail the declared-shape probe, deliberately. All four document
Architecture.InputDepthas the per-frame channel count, so their declared shape is ONE frame ([3, H, W]) whilePredictneeds a stacked PAIR ([6, H, W]).GetInputShape()is derived from the architecture, so there is no way to make the declared shape the pair without redefining whatInputDepthmeans for these models — a public constructor argument's meaning.The evidence that this is a deliberate, documented contract and not an oversight:
_channels = architecture.InputDepth(RAFT.cs:176); its<example>passesinputDepth: 3;VideoExtendedIntegrationTests.RAFT_InputChannels_DefaultsToThreeassertsInputChannels == 3for a depth-3 architecture; the generated fixture builds it withinputDepth: 3and feeds[1,6,64,64];SEARAFT.cs:78-81explicitly excludes RAFT and GMFlow from the "declare depth 6" rule because they have a separate 3-channel context encoder.GMFlow.cs:153), sameinputDepth: 3example and fixture.TryGetArchitectureInputShape() => nullwith a doc comment stating "Architecture.InputDepthitself reports the SINGLE-FRAME count (3)", andTestScaffoldGenerator.cs:9545-9547states the same in the fixture comment.FrameInterpolationBase.cs:171-184, RIFE's ctor comment, its<example>, and the generator's fixture note all defineInputDepthas per-frame. Changing it touches ~21 models (andFILM's fixture already contradicts the family doc by passinginputDepth: 6, so the family is already inconsistent).Changing
InputDepthto mean the stacked pair for these four is a coherent option (13 of the 17 flow models already do it, andOpticalFlowBase's note now says "most of the family"), but it would flip a documented public contract, break the assertions above, and for RIFE reverse a whole family's rule. Per the "user has final say on scope decisions" rule I stopped at evidence + recommendation. Recommended: adopt "InputDepth = 2×channels" for RAFT, GMFlow and RAPIDFlow (changing_channels = InputDepth / 2and the fixtures in lockstep so parameter counts do not move), and treat the frame-interpolation family separately as its own decision.VideoCLIP: a latent bug the declared-shape change exposed
With any
FourDimensionalarchitecture — not just the new default — the base lazy-shape walk (NeuralNetworkBase.ResolveLazyLayerShapes,:5542) starts from a rank-5 clip that VideoCLIP's first convolution rejects, so the stack is left unresolved until the first forward andParameterCountunder-reports at construction:new VideoCLIP<float>()ParameterCountat constructionThat matters beyond cosmetics:
ParameterCountis what the weight-streaming auto-detect,ShouldUseStreamingTrainingandConfigureInferenceForScalegate on — the base class's own comment on that walk calls out that an under-reported count "blinded every ParameterCount-gated memory decision". VideoCLIP now overridesTryGetArchitectureInputShape()to return one frame[1, C, H, W], because its layer stack genuinely runs per frame (the spatial encoder consumes frames, not clips). Precedent for the override:RAPIDFlow,UFM,DPFlow,MiDaS,FrameInterpolationBase,Transformer,PANNsModel.Regression test
VideoCLIP_DeclaringTheClip_DoesNotChangeTheResolvedLayerStack(small config): before 2,304 parameters vs 50,477 for the equivalent one-frame declaration; after, both 50,477.3. TimeSformer
Frame count.
src/Video/ActionRecognition/TimeSformer.cs:223set_numFrames = numFrames(default 8) and ignoredArchitecture.InputFrames, so a 16-frame architecture built an 8-frame model:NumFramesand metadata said 8, the positional table was sized8 * patches + 1, and — because the blocks are constructed with that count —TimeSformerBlockLayer.Forward(input)(the frame-count-less overload used whenever the layer is run on its own) grouped a 16-frame token sequence into 8 frames. It now applies VideoMAE's rule; the ONNX constructor's hard-coded_numFrames = 8(:263) reads the architecture too.The rule moved into a new internal
src/Video/VideoClipFrameCount.csshared by VideoMAE, TimeSformer and VideoCLIP: the architecture's declared count wins; an explicit non-defaultnumFramesthat disagrees throwsArgumentException(nameof(numFrames)). VideoMAE's behaviour is unchanged (it delegates to the helper; its 10 existing tests still pass).Unbatched output.
Classifyreturned[1, NumClasses]for a[T,C,H,W]clip. Its own XML doc says[NumClasses], the model's output[TensorLayout]isBatchOptional, and the basePredictCoresqueezes the batch it adds — three statements of the same convention. Now squeezed (tokenized path only; a caller-supplied layer stack is left untouched).Batch softmax (numerical bug).
TimeSformer.cs:401's hand-rolled softmax took the max and the sum over every element of the logits tensor, so for a batch of B clips the whole[B, NumClasses]matrix summed to 1 rather than each row: each clip's "probabilities" were scaled by how confident the other clips in the batch happened to be. Measured on a 3-clip batch: row sum 0.325 instead of 1.0. Replaced withEngine.Softmaxover the class axis.Also moved the native constructor's XML docs, which were attached to the parameterless constructor (two
<summary>blocks in a row), onto the constructor they describe, and documentednumFramesandoptions.Default parameter count unchanged: 113,541,120.
4. VideoMAE pretraining fidelity (Tong et al. 2022)
(a)
PretrainMAEnever trained.src/Video/ActionRecognition/VideoMAE.cs:345computed a reconstruction loss and returned it — no backward, no optimizer step. The class docs, the<example>, and the name all describe pretraining, so it is meant to train. It now records the masked forward and the loss on aGradientTapeand steps throughBackwardAndStepOnPrecomputedLoss— the library's caller-owned-tape training entry point used by NeRF, TVAE, TabDDPM and the GANs — with the constructor's optimizer or the model's default Adam. The loss is now built from engine ops (subtract → square → multiply by a 0/1 masked-patch tensor → sum → scale) so it back-propagates;ComputeReconstructionLossreturns that same objective's value under aNoGradScope.Measured on a fixed clip (small config: 4 frames, 32×32, 16 features, mask ratio 0.5, default Adam lr 1e-3):
Which layers update (one step, verified per layer): patch embedding (0), all 12 encoder blocks (1-12), all 4 decoder blocks (16-19) and the reconstruction head (20) — and not the classification head (13 feature-reduce, 15 classifier), which is not on the pretraining path. Before: none of them.
(b) Masked tokens were zeroed, not dropped.
VideoMAE.cs:743wrote zeros into the patch embedding in place, once. Two defects followed:The paper's encoder drops masked tokens; a convolution over the patch grid cannot drop grid positions. So it is made sparse the way masked-image-modelling work on convolutional encoders does it (SparK, Tian et al. 2023; ConvNeXt V2's FCMAE, Woo et al. 2023): a recorded visibility mask multiplies the patch embedding and the output of every encoder block. A masked position then holds exactly zero at every block input — it contributes nothing to its visible neighbours (identical to zero padding) and never accumulates a value or a gradient of its own.
Deliberate deviation from the paper: the learned mask token is not added. It would be a new trainable parameter (and would change every default parameter count), so masked positions enter the decoder as a fixed zero "mask token" and the decoder's convolutions fill them from the visible context. This is documented on
EncodeVisiblePatchesand in the class remarks rather than left implicit.(c) Tape-severing GELU in the decoder.
VideoMAE.cs:792stacked aTensor.Transform-based GELU on every decoder block's own ReLU.Transformrecords no autodiff node, so it severed the tape after every block: a reconstruction loss could reach the head and nothing before it — no decoder block, no encoder block, no patch embedding. It was also numerically redundant (GELU of a non-negative ReLU output). Removed — the identical defect had already been fixed in the encoder on the base branch.(d) No per-patch target normalisation. The target was raw pixels (
ComputeReconstructionLoss,:828). NewVideoMAEOptions.NormalizeTarget, defaulting totrue— the paper'snormlize_targetdefault, inherited from MAE (He et al. 2022, §4) — normalises every channel of every tubelet patch over its owntubeletSize × 16 × 16pixels as(x - mean) / (std + 1e-6)with the unbiased standard deviation, exactly the reference implementation'sb (t h w) (p0 p1 p2) cview reduced overp0 p1 p2. Setting it tofalserestores the raw-pixel target bit-exactly (the existing exact-value test now pins that path). Documented on the option, onComputeReconstructionLoss, on the target builder and in the class remarks.No parameters are added anywhere: default VideoMAE parameter count unchanged at 65,788,816.
Test results (net10.0, Release)
Baseline = this branch's base commit
4dedc7599; "after" = this PR's head. Same machine, same filter.TracedChainValidationTestsVideoExtendedIntegrationTestsRAPIDFlowReviewRegressionIntegrationTestsShapeContractConformanceTestsTensorLayoutRankTestsNeither failure is a regression. Both are the same single test,
TracedChainValidationTests.TracedValidationClearsTheFalsePositivesTheLinearReadingProduces(modelName: "VideoMAE"), failing identically before and after withSystem.ArgumentOutOfRangeException : Inner left-operand block exceeds its span. (Parameter 'aBase'). That is the one-frame default-constructor crash owned by the never-run-tests PR; PR #2174 already documented it and deliberately kept clear of those lines, and so does this PR.The 2 new tests that pass before the fix are guards, not evidence: TimeSformer's "no declared frame count keeps the
numFramesargument" and the options default (which reads the stub described below).ModelFamilyLawTests.DoTheMembersOfAFamilyShareOneShapeLawtimed out locally after its own 30-minute limit, so I have no before/after for it. It is the whole-model-zoo sweep (~900 constructions) that is known to time out unreliably on a local box; it was not part of the baseline run either, so I am reporting it as unmeasured rather than as a pass or a failure. CI is the arbiter for that one.How the fails-before numbers were produced
git stash push --include-untracked -- src(source only, tests left in place), then two API-surface-only stubs re-applied to the unfixed source so the new tests compile against the old logic:VideoMAE.EncodeVisiblePatchesprivate→internal(visibility, no behaviour),VideoMAEOptions.NormalizeTargetproperty (the unfixed model never looks at it).Then a full rebuild and run. Afterwards the stash was popped and the restored working tree was verified byte-identical to the tested tree (
git diffvs the saved patch: identical; the new helper file compared byte-for-byte). The numbers above come from that run: 33 of 35 new tests fail against the unfixed source with the exact errors quoted in the per-item sections.Builds
src/AiDotNet.csprojcompiles with 0 errors for net10.0, net8.0 and net471 (the library multi-targets all three; net471 lacks several modern BCL APIs, so it is built explicitly).Adversarial review
Default parameter counts (measured, before → after): VideoMAE 65,788,816 → 65,788,816 · TimeSformer 113,541,120 → 113,541,120 · VideoCLIP 211,126,273 → 211,126,273 (would have been 38,004,480 without the walk fix) · RAFT 6,277,122 → 6,277,122 · SKFlow 300,098 → 300,098 · ProPainter 22,008,067 → 22,008,067 · BasicVSR++ 0 → 0 (fully lazy).
Clone / serialize:
NumFramesandNormalizeTarget; the clone can runPretrainMAE; serialize → deserialize preserves parameters,NormalizeTarget, and produces bit-identical predictions.NumFrames = 16(before: 16 → 8), and deserialize now yields an identical prediction (before: different, because the rebuilt model's frame count came back as 8 and re-grouped the tokens).NumFrames.[2,16,16](before: the clone threw the rank-4ArgumentException).Blast radius / file map
Source (10 files, all under
src/Video)Video/OpticalFlowBase.csPredictCore; body split intoPredictBatchedPair; both[TensorLayout]sBatchOptional;OutputAxesFor(3)Video/Motion/RAFT.csVideo/ActionRecognition/TimeSformer.csVideo/ActionRecognition/VideoMAE.csVideo/Options/VideoMAEOptions.csNormalizeTargetoption (defaulttrue)Video/VideoClipFrameCount.csVideo/Enhancement/BasicVSRPlusPlus.csEnhanceVideoVideo/Generation/OpenSora.csPredictCoreVideo/Inpainting/ProPainter.csPredictCoreVideo/Understanding/VideoCLIP.csTryGetArchitectureInputShapeNothing outside
src/Videois touched — no change toNeuralNetworkBase,LayerHelper, the generators or any shared layer. No visibility was widened beyond oneprivate→internalinsideVideoMAEfor its own test (the same pattern the base branch already uses forDecodeForReconstruction,CreateTubeMaskandComputeReconstructionLoss).Tests (5 files): new
UnitTests/Video/Motion/OpticalFlowDeclaredInputTests.cs(15),UnitTests/Video/VideoModelDeclaredInputTests.cs(7),UnitTests/Video/ActionRecognition/TimeSformerFrameCountTests.cs(7),UnitTests/Video/ActionRecognition/VideoMAEPretrainingFidelityTests.cs(6); plusVideoMAEPretrainingTests.cs— one existing exact-value test now constructs the model withNormalizeTarget = false, because it pins the raw-pixel correspondence by hand; the normalised default is pinned by a new test.Behavioural changes callers could notice (all of them the point of the PR): unbatched optical-flow
Predictnow returns[2,H,W]instead of throwing; TimeSformer's unbatchedPredictreturns[classes]instead of[1, classes]; a batched TimeSformerPredictreturns per-clip probabilities instead of batch-normalised ones;PretrainMAEnow mutates weights and its loss is on normalised targets by default; a 16-frame TimeSformer / VideoCLIP architecture now builds a 16-/N-frame model instead of silently using the default.What this does not do
Found while working, not fixed here — each needs its own change and its own test:
VideoMAE.TraincallsTrainWithTape(input, expectedOutput)without the constructor's_optimizer, so a user-supplied optimizer is silently ignored (604 other call sites pass it; TimeSformer does).PretrainMAEin this PR does use it. Left out because a regression test needs a non-fusable optimizer probe to observe which optimizer actually ran.[TensorLayout(Input)]is[Batch, Time, Features]whilePredictCoretakes[B, C, H, W], and itsTraincomputes a gradient it never back-propagates. Its default config is ~446M parameters, so the declared-shape probe is only practical at a small config.Predictnever actually inpaints anything.[F, C, H, W]while the inherited super-resolution layout says[B, C, H, W]; andVideoSuperResolutionBase.EstimateFlowdefaults to an untrained full-sizenew RAFT<T>(), so every VSR model that does not override it aligns frames with random flow.Shape[0] / 2with no even-channel check.Plus the four declared-shape models in the decision section above.
🤖 Generated with Claude Code
https://claude.ai/code/session_017otuSGr3GdmvPoYLbiaWR2