Instructions to use Frankenstein-Labs/cortex.6.sol with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use Frankenstein-Labs/cortex.6.sol with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("text-generation", model="Frankenstein-Labs/cortex.6.sol")# pip install -U transformers accelerate # Load model directly from transformers import AutoTokenizer, AutoModelForCausalLM tokenizer = AutoTokenizer.from_pretrained("Frankenstein-Labs/cortex.6.sol") model = AutoModelForCausalLM.from_pretrained("Frankenstein-Labs/cortex.6.sol", device_map="auto") - Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- vLLM
How to use Frankenstein-Labs/cortex.6.sol with vLLM:
Install from pip and serve model
# Install vLLM from pip: pip install vllm # Start the vLLM server: vllm serve "Frankenstein-Labs/cortex.6.sol" # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:8000/v1/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "Frankenstein-Labs/cortex.6.sol", "prompt": "Once upon a time,", "max_tokens": 512, "temperature": 0.5 }'Use Docker
docker model run hf.co/Frankenstein-Labs/cortex.6.sol
- SGLang
How to use Frankenstein-Labs/cortex.6.sol with SGLang:
Install from pip and serve model
# Install SGLang from pip: pip install sglang # Start the SGLang server: python3 -m sglang.launch_server \ --model-path "Frankenstein-Labs/cortex.6.sol" \ --host 0.0.0.0 \ --port 30000 # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:30000/v1/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "Frankenstein-Labs/cortex.6.sol", "prompt": "Once upon a time,", "max_tokens": 512, "temperature": 0.5 }'Use Docker images
docker run --gpus all \ --shm-size 32g \ -p 30000:30000 \ -v ~/.cache/huggingface:/root/.cache/huggingface \ --env "HF_TOKEN=<secret>" \ --ipc=host \ lmsysorg/sglang:latest \ python3 -m sglang.launch_server \ --model-path "Frankenstein-Labs/cortex.6.sol" \ --host 0.0.0.0 \ --port 30000 # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:30000/v1/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "Frankenstein-Labs/cortex.6.sol", "prompt": "Once upon a time,", "max_tokens": 512, "temperature": 0.5 }' - Docker Model Runner
How to use Frankenstein-Labs/cortex.6.sol with Docker Model Runner:
docker model run hf.co/Frankenstein-Labs/cortex.6.sol
feat(vision): trainable stage-1 vision projector with frozen encoder and LM
Browse filesImplements the first genuinely trainable CORTEX vision pipeline, isolated from the
DeepSeek-V4-Pro checkpoint:
IMAGE -> real SigLIP encoder (frozen) -> VisionProjector (trained)
-> PrefixFusion -> small real causal LM (frozen) -> logits
Only VisionProjector is trained. On the CPU sandbox the run converges from loss
7.5445 to 5.1408 over 60 steps on real Flickr30k image/caption pairs, with the
encoder and LM parameter fingerprints unchanged at exactly 0.
- cortex_ai/vision/train.py: config, freezing helpers, fused labels/mask, training
loop, evidence report and separate projector checkpoint.
- examples/train_vision_projector.py + configs/vision_projector.json: reproducible
CPU entry point; every config field is also a CLI flag.
- VisionProjector gains a two-layer 'mlp' type; 'linear' stays the default.
- Fix the same BatchFeature/UserDict bug in VisionAdapter.encode_images that was
fixed in native.py, and extend it with encode_pixels for preprocessed batches.
- tests/test_projector_training.py: 9 tests covering gradient flow, frozen-weight
preservation, projector change, before/after execution, image sensitivity and
mask/label alignment. One real-model test is opt-in via CORTEX_REAL_VISION_TEST=1.
- tools/shard_integrity.py: hashes the 66 shard LFS pointers so weight integrity is
checkable without downloading ~893 GB.
- docs/vision_projector_training.md: explicit PROVEN vs NOT PROVEN scope.
Not proven: that this projector works with the real 1.65T DeepSeek-V4-Pro core
(7168-dim), native multimodal understanding, or final CORTEX performance.
Co-authored-by: openhands <openhands@all-hands.dev>
- .gitignore +8 -0
- AGENTS.md +41 -3
- configs/vision_projector.json +19 -0
- cortex_ai/vision/__init__.py +18 -0
- cortex_ai/vision/core.py +29 -5
- cortex_ai/vision/native.py +18 -1
- cortex_ai/vision/train.py +310 -0
- docs/vision_projector_training.md +105 -0
- examples/train_vision_projector.py +158 -0
- tests/test_projector_training.py +259 -0
- tools/shard_integrity.py +102 -0
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.py[cod]
|
| 3 |
+
.pytest_cache/
|
| 4 |
+
.ipynb_checkpoints/
|
| 5 |
+
|
| 6 |
+
# Stage-1 projector checkpoints are reproducible artefacts, not source.
|
| 7 |
+
checkpoints/
|
| 8 |
+
*.pt
|
|
@@ -88,8 +88,46 @@ tower): 196 image tokens + 8 text tokens -> 204-length fused sequence, logits sh
|
|
| 88 |
## Test commands
|
| 89 |
|
| 90 |
```bash
|
| 91 |
-
PYTHONPATH=/workspace/project python3 -m pytest tests/ -q
|
| 92 |
-
|
|
|
|
|
|
|
| 93 |
```
|
| 94 |
|
| 95 |
-
Requires `torch` (CPU wheel), `torchvision`, `pillow`, `transformers`.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
## Test commands
|
| 89 |
|
| 90 |
```bash
|
| 91 |
+
PYTHONPATH=/workspace/project python3 -m pytest tests/ -q # 14 passed, 1 skipped
|
| 92 |
+
PYTHONPATH=/workspace/project/encoding python3 -m pytest encoding/test_encoding_dsv4.py -q # 4 tests
|
| 93 |
+
CORTEX_REAL_VISION_TEST=1 python3 -m pytest tests/ -q -k real # real SigLIP + pythia
|
| 94 |
+
python tools/shard_integrity.py --from-worktree --out /tmp/after.json # 66 shard hashes
|
| 95 |
```
|
| 96 |
|
| 97 |
+
Requires `torch` (CPU wheel), `torchvision`, `pillow`, `transformers`, and `datasets` for training.
|
| 98 |
+
|
| 99 |
+
## Stage-1 projector training (implemented and verified)
|
| 100 |
+
|
| 101 |
+
`cortex_ai/vision/train.py` + `examples/train_vision_projector.py` train **only** the projector:
|
| 102 |
+
|
| 103 |
+
```
|
| 104 |
+
IMAGE -> frozen SigLIP -> VisionProjector (trained) -> PrefixFusion -> frozen causal LM -> logits
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
Verified numbers on this CPU sandbox (4 cores, ~0.8 s/step at batch size 2):
|
| 108 |
+
|
| 109 |
+
| Quantity | Value |
|
| 110 |
+
|---|---|
|
| 111 |
+
| vision_hidden_size | 768 (`google/siglip-base-patch16-224` vision tower, 196 tokens/image) |
|
| 112 |
+
| cortex_hidden_size | 128 (`EleutherAI/pythia-14m`) |
|
| 113 |
+
| projector | `mlp`, 768 -> 128, **114,944 params** |
|
| 114 |
+
| fused sequence | 220 = 196 image + 24 text |
|
| 115 |
+
| 60-step run | loss 7.5445 -> 5.1408; projector delta 669.6; encoder/LM delta exactly 0 |
|
| 116 |
+
|
| 117 |
+
Invariants covered by `tests/test_projector_training.py` (9 tests): projector gets gradients and
|
| 118 |
+
frozen modules get none; projector params change while encoder/LM are byte-identical; the pipeline
|
| 119 |
+
runs before and after training; different images give different fused representations; the fused
|
| 120 |
+
attention mask and labels align with the fused sequence.
|
| 121 |
+
|
| 122 |
+
Gotchas found while building this:
|
| 123 |
+
|
| 124 |
+
- `pythia-14m` loads as **float16** by default, which breaks CPU LayerNorm against an fp32
|
| 125 |
+
projector. Always pin `dtype=torch.float32` explicitly.
|
| 126 |
+
- Passing the **text** attention mask into a fused forward pass fails with
|
| 127 |
+
`size of tensor a (204) must match tensor b (8)`. Image-token positions need their own mask
|
| 128 |
+
entries; see `fused_attention_mask`.
|
| 129 |
+
- Causal-LM loss is reported at *every* position, so image-token positions must be set to `-100`
|
| 130 |
+
or the projector is trained to predict text from its own prefix.
|
| 131 |
+
|
| 132 |
+
NOT proven: that this projector works with the real DeepSeek-V4-Pro core (7168-dim), native
|
| 133 |
+
multimodal understanding of the 1.65 T checkpoint, or final CORTEX AI performance.
|
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"vision_model": "google/siglip-base-patch16-224",
|
| 3 |
+
"language_model": "EleutherAI/pythia-14m",
|
| 4 |
+
"projector_type": "mlp",
|
| 5 |
+
"dtype": "float32",
|
| 6 |
+
"device": "cpu",
|
| 7 |
+
"learning_rate": 0.001,
|
| 8 |
+
"weight_decay": 0.0,
|
| 9 |
+
"epochs": 1,
|
| 10 |
+
"batch_size": 2,
|
| 11 |
+
"max_steps": 40,
|
| 12 |
+
"max_text_tokens": 24,
|
| 13 |
+
"dataset": "clip-benchmark/wds_flickr30k",
|
| 14 |
+
"dataset_split": "test",
|
| 15 |
+
"seed": 0,
|
| 16 |
+
"output_checkpoint": "checkpoints/vision_projector.pt",
|
| 17 |
+
"log_every": 10,
|
| 18 |
+
"limit": 128
|
| 19 |
+
}
|
|
@@ -11,6 +11,24 @@ __all__ = [
|
|
| 11 |
if "NativeVisionFusion" in globals():
|
| 12 |
__all__ += ["NativeFusionResult", "NativeVisionFusion", "build_native_fusion"]
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
try:
|
| 15 |
from .core import (
|
| 16 |
ImageProcessor,
|
|
|
|
| 11 |
if "NativeVisionFusion" in globals():
|
| 12 |
__all__ += ["NativeFusionResult", "NativeVisionFusion", "build_native_fusion"]
|
| 13 |
|
| 14 |
+
try:
|
| 15 |
+
from .train import (
|
| 16 |
+
ProjectorTrainingConfig,
|
| 17 |
+
TrainingReport,
|
| 18 |
+
build_fusion_pipeline,
|
| 19 |
+
run_training,
|
| 20 |
+
save_projector,
|
| 21 |
+
train_step,
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
__all__ += [
|
| 25 |
+
"ProjectorTrainingConfig", "TrainingReport", "build_fusion_pipeline",
|
| 26 |
+
"run_training", "save_projector", "train_step",
|
| 27 |
+
]
|
| 28 |
+
except ImportError:
|
| 29 |
+
# Training helpers require the optional dataset/transformers stack.
|
| 30 |
+
pass
|
| 31 |
+
|
| 32 |
try:
|
| 33 |
from .core import (
|
| 34 |
ImageProcessor,
|
|
@@ -6,6 +6,7 @@ separate approved checkpoint). The adapter can therefore be tested with a
|
|
| 6 |
small local encoder while remaining honest about deployment readiness.
|
| 7 |
"""
|
| 8 |
|
|
|
|
| 9 |
from dataclasses import dataclass
|
| 10 |
from typing import Any, Protocol, Sequence
|
| 11 |
|
|
@@ -47,13 +48,31 @@ class ImageProcessor(Protocol):
|
|
| 47 |
|
| 48 |
|
| 49 |
class VisionProjector(nn.Module):
|
| 50 |
-
"""Map encoder features into CORTEX hidden space.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
def __init__(self, config: VisionConfig) -> None:
|
| 53 |
super().__init__()
|
| 54 |
-
if config.projector_type
|
| 55 |
-
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
def forward(self, features: Tensor) -> Tensor:
|
| 59 |
if features.ndim != 3:
|
|
@@ -73,7 +92,12 @@ class VisionAdapter(nn.Module):
|
|
| 73 |
|
| 74 |
def encode_images(self, images: Any) -> Tensor:
|
| 75 |
model_inputs = self.processor(images, return_tensors="pt") if self.processor else images
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
if hasattr(features, "last_hidden_state"):
|
| 78 |
features = features.last_hidden_state
|
| 79 |
if not isinstance(features, Tensor):
|
|
|
|
| 6 |
small local encoder while remaining honest about deployment readiness.
|
| 7 |
"""
|
| 8 |
|
| 9 |
+
from collections.abc import Mapping
|
| 10 |
from dataclasses import dataclass
|
| 11 |
from typing import Any, Protocol, Sequence
|
| 12 |
|
|
|
|
| 48 |
|
| 49 |
|
| 50 |
class VisionProjector(nn.Module):
|
| 51 |
+
"""Map encoder features into CORTEX hidden space.
|
| 52 |
+
|
| 53 |
+
``projector_type='linear'`` is a single linear map. ``projector_type='mlp'`` is the
|
| 54 |
+
LLaVA-style two-layer gated projection, whose intermediate width equals the target
|
| 55 |
+
hidden size. Only this module is trained in the first experimental stage.
|
| 56 |
+
"""
|
| 57 |
|
| 58 |
def __init__(self, config: VisionConfig) -> None:
|
| 59 |
super().__init__()
|
| 60 |
+
if config.projector_type == "linear":
|
| 61 |
+
self.projection: nn.Module = nn.Linear(
|
| 62 |
+
config.vision_hidden_size, config.cortex_hidden_size
|
| 63 |
+
)
|
| 64 |
+
elif config.projector_type == "mlp":
|
| 65 |
+
self.projection = nn.Sequential(
|
| 66 |
+
nn.Linear(config.vision_hidden_size, config.cortex_hidden_size),
|
| 67 |
+
nn.GELU(),
|
| 68 |
+
nn.Linear(config.cortex_hidden_size, config.cortex_hidden_size),
|
| 69 |
+
)
|
| 70 |
+
else:
|
| 71 |
+
raise ValueError(
|
| 72 |
+
f"Unknown projector_type={config.projector_type!r}; "
|
| 73 |
+
"expected 'linear' or 'mlp'"
|
| 74 |
+
)
|
| 75 |
+
self.config = config
|
| 76 |
|
| 77 |
def forward(self, features: Tensor) -> Tensor:
|
| 78 |
if features.ndim != 3:
|
|
|
|
| 92 |
|
| 93 |
def encode_images(self, images: Any) -> Tensor:
|
| 94 |
model_inputs = self.processor(images, return_tensors="pt") if self.processor else images
|
| 95 |
+
# Same BatchFeature caveat as native.py: HF processors return a UserDict, which is
|
| 96 |
+
# not a dict subclass, so it must be tested as a Mapping before unpacking by keyword.
|
| 97 |
+
if isinstance(model_inputs, Mapping):
|
| 98 |
+
features = self.encoder(**model_inputs)
|
| 99 |
+
else:
|
| 100 |
+
features = self.encoder(model_inputs)
|
| 101 |
if hasattr(features, "last_hidden_state"):
|
| 102 |
features = features.last_hidden_state
|
| 103 |
if not isinstance(features, Tensor):
|
|
@@ -55,6 +55,18 @@ class NativeVisionFusion(nn.Module):
|
|
| 55 |
encoded = self.vision_encoder(**model_input)
|
| 56 |
else:
|
| 57 |
encoded = self.vision_encoder(model_input)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
if hasattr(encoded, "last_hidden_state"):
|
| 59 |
encoded = encoded.last_hidden_state
|
| 60 |
if not isinstance(encoded, Tensor) or encoded.ndim != 3:
|
|
@@ -90,7 +102,12 @@ def build_native_fusion(
|
|
| 90 |
vision_hidden_size: int,
|
| 91 |
cortex_hidden_size: int,
|
| 92 |
image_processor: Callable[..., Any] | None = None,
|
|
|
|
| 93 |
) -> NativeVisionFusion:
|
| 94 |
"""Construct the native path around externally supplied encoder and core modules."""
|
| 95 |
-
config = VisionConfig(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
return NativeVisionFusion(vision_encoder, config, core, image_processor)
|
|
|
|
| 55 |
encoded = self.vision_encoder(**model_input)
|
| 56 |
else:
|
| 57 |
encoded = self.vision_encoder(model_input)
|
| 58 |
+
return self._project(encoded)
|
| 59 |
+
|
| 60 |
+
def encode_pixels(self, pixel_values: Tensor, **encoder_kwargs: Any) -> Tensor:
|
| 61 |
+
"""Project already-preprocessed ``pixel_values`` into the core's hidden space.
|
| 62 |
+
|
| 63 |
+
Training loops preprocess whole batches once, so they use this instead of
|
| 64 |
+
``encode_image`` to avoid re-running the image processor on every step. The encoder is
|
| 65 |
+
called by keyword here, which suits Transformers vision towers.
|
| 66 |
+
"""
|
| 67 |
+
return self._project(self.vision_encoder(pixel_values=pixel_values, **encoder_kwargs))
|
| 68 |
+
|
| 69 |
+
def _project(self, encoded: Any) -> Tensor:
|
| 70 |
if hasattr(encoded, "last_hidden_state"):
|
| 71 |
encoded = encoded.last_hidden_state
|
| 72 |
if not isinstance(encoded, Tensor) or encoded.ndim != 3:
|
|
|
|
| 102 |
vision_hidden_size: int,
|
| 103 |
cortex_hidden_size: int,
|
| 104 |
image_processor: Callable[..., Any] | None = None,
|
| 105 |
+
projector_type: str = "linear",
|
| 106 |
) -> NativeVisionFusion:
|
| 107 |
"""Construct the native path around externally supplied encoder and core modules."""
|
| 108 |
+
config = VisionConfig(
|
| 109 |
+
vision_hidden_size=vision_hidden_size,
|
| 110 |
+
cortex_hidden_size=cortex_hidden_size,
|
| 111 |
+
projector_type=projector_type,
|
| 112 |
+
)
|
| 113 |
return NativeVisionFusion(vision_encoder, config, core, image_processor)
|
|
@@ -0,0 +1,310 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Trainable vision projector for CORTEX AI (experimental, stage 1).
|
| 2 |
+
|
| 3 |
+
Pipeline exercised here::
|
| 4 |
+
|
| 5 |
+
IMAGE -> real vision encoder -> VisionProjector -> PrefixFusion
|
| 6 |
+
-> small real frozen causal LM -> logits -> causal LM loss
|
| 7 |
+
|
| 8 |
+
The vision encoder and the causal LM stay frozen; **only** ``VisionProjector`` is trained.
|
| 9 |
+
This module is deliberately independent of the 1.65 T DeepSeek-V4-Pro checkpoint: it neither
|
| 10 |
+
reads nor modifies the model-*-of-*.safetensors shards, and the projector checkpoint it writes
|
| 11 |
+
is a separate file.
|
| 12 |
+
|
| 13 |
+
Scope note: a projector trained against a small stand-in LM demonstrates that the projector is
|
| 14 |
+
genuinely trainable and that the frozen-weight invariants hold. It does **not** demonstrate that
|
| 15 |
+
the trained projector works with the real DeepSeek-V4-Pro core, nor native multimodal
|
| 16 |
+
understanding of that checkpoint.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import json
|
| 22 |
+
from dataclasses import asdict, dataclass, field
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
from typing import Any, Callable, Iterable, Sequence
|
| 25 |
+
|
| 26 |
+
import torch
|
| 27 |
+
from torch import Tensor, nn
|
| 28 |
+
|
| 29 |
+
from .core import PrefixFusion, VisionConfig, VisionProjector
|
| 30 |
+
from .native import NativeVisionFusion, build_native_fusion
|
| 31 |
+
|
| 32 |
+
IGNORE_INDEX = -100
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@dataclass
|
| 36 |
+
class ProjectorTrainingConfig:
|
| 37 |
+
"""Explicit dimensions, dtype and optimisation settings for projector training."""
|
| 38 |
+
|
| 39 |
+
vision_model: str = "google/siglip-base-patch16-224"
|
| 40 |
+
language_model: str = "EleutherAI/pythia-14m"
|
| 41 |
+
projector_type: str = "mlp"
|
| 42 |
+
dtype: str = "float32"
|
| 43 |
+
device: str = "cpu"
|
| 44 |
+
learning_rate: float = 1e-3
|
| 45 |
+
weight_decay: float = 0.0
|
| 46 |
+
epochs: int = 1
|
| 47 |
+
batch_size: int = 2
|
| 48 |
+
max_steps: int = 40
|
| 49 |
+
max_text_tokens: int = 24
|
| 50 |
+
dataset: str = "clip-benchmark/wds_flickr30k"
|
| 51 |
+
dataset_split: str = "test"
|
| 52 |
+
seed: int = 0
|
| 53 |
+
output_checkpoint: str = "checkpoints/vision_projector.pt"
|
| 54 |
+
log_every: int = 10
|
| 55 |
+
limit: int | None = 128
|
| 56 |
+
|
| 57 |
+
def resolved_dtype(self) -> torch.dtype:
|
| 58 |
+
return {"float32": torch.float32, "float16": torch.float16,
|
| 59 |
+
"bfloat16": torch.bfloat16}[self.dtype]
|
| 60 |
+
|
| 61 |
+
def resolved_device(self) -> torch.device:
|
| 62 |
+
return torch.device(self.device)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@dataclass
|
| 66 |
+
class TrainingReport:
|
| 67 |
+
"""Recorded evidence from a training run."""
|
| 68 |
+
|
| 69 |
+
vision_hidden_size: int
|
| 70 |
+
lm_hidden_size: int
|
| 71 |
+
image_token_count: int
|
| 72 |
+
text_token_count: int
|
| 73 |
+
fused_sequence_length: int
|
| 74 |
+
projector_parameters: int
|
| 75 |
+
dtype: str
|
| 76 |
+
device: str
|
| 77 |
+
steps: int
|
| 78 |
+
loss_first: float
|
| 79 |
+
loss_last: float
|
| 80 |
+
loss_history: list[float] = field(default_factory=list)
|
| 81 |
+
projector_changed: bool = False
|
| 82 |
+
projector_delta: float = 0.0
|
| 83 |
+
encoder_unchanged: bool = False
|
| 84 |
+
lm_unchanged: bool = False
|
| 85 |
+
checkpoint_path: str = ""
|
| 86 |
+
|
| 87 |
+
def to_json(self) -> str:
|
| 88 |
+
return json.dumps(asdict(self), indent=2)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def freeze_module(module: nn.Module) -> int:
|
| 92 |
+
"""Set requires_grad=False everywhere and return the frozen parameter count."""
|
| 93 |
+
n = 0
|
| 94 |
+
for p in module.parameters():
|
| 95 |
+
p.requires_grad_(False)
|
| 96 |
+
n += p.numel()
|
| 97 |
+
module.eval()
|
| 98 |
+
return n
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def count_parameters(module: nn.Module, trainable_only: bool = False) -> int:
|
| 102 |
+
return sum(p.numel() for p in module.parameters() if (p.requires_grad or not trainable_only))
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def parameter_fingerprint(module: nn.Module) -> dict[str, Tensor]:
|
| 106 |
+
"""Detached clone of every parameter, for before/after comparison."""
|
| 107 |
+
return {k: v.detach().clone() for k, v in module.state_dict().items()}
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def fingerprint_delta(before: dict[str, Tensor], after: dict[str, Tensor]) -> float:
|
| 111 |
+
"""Total absolute change across all tensors in two fingerprints."""
|
| 112 |
+
total = 0.0
|
| 113 |
+
for k, v in before.items():
|
| 114 |
+
if k not in after:
|
| 115 |
+
continue
|
| 116 |
+
total += (after[k].to(torch.float32) - v.to(torch.float32)).abs().sum().item()
|
| 117 |
+
return total
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def build_fusion_pipeline(
|
| 121 |
+
vision_encoder: nn.Module,
|
| 122 |
+
core: nn.Module,
|
| 123 |
+
config: ProjectorTrainingConfig,
|
| 124 |
+
image_processor: Callable[..., Any] | None = None,
|
| 125 |
+
) -> tuple[NativeVisionFusion, int, int]:
|
| 126 |
+
"""Assemble IMAGE -> encoder -> projector -> PrefixFusion -> frozen core."""
|
| 127 |
+
vision_hidden = int(vision_encoder.config.hidden_size)
|
| 128 |
+
lm_hidden = int(core.config.hidden_size)
|
| 129 |
+
fusion = build_native_fusion(
|
| 130 |
+
vision_encoder,
|
| 131 |
+
core,
|
| 132 |
+
vision_hidden_size=vision_hidden,
|
| 133 |
+
cortex_hidden_size=lm_hidden,
|
| 134 |
+
image_processor=image_processor,
|
| 135 |
+
projector_type=config.projector_type,
|
| 136 |
+
)
|
| 137 |
+
# The encoder and the core are frozen; the projector is the only trained part.
|
| 138 |
+
freeze_module(fusion.vision_encoder)
|
| 139 |
+
freeze_module(fusion.core)
|
| 140 |
+
fusion.projector.train()
|
| 141 |
+
return fusion, vision_hidden, lm_hidden
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def fused_labels(image_token_count: int, input_ids: Tensor) -> Tensor:
|
| 145 |
+
"""Labels aligned to the fused sequence.
|
| 146 |
+
|
| 147 |
+
Causal-LM loss is reported at every position, so the image-token positions would otherwise
|
| 148 |
+
be trained to predict ordinary text tokens from a vision prefix. They are masked out, so the
|
| 149 |
+
gradient on the projector comes from predicting the caption tokens conditioned on the image.
|
| 150 |
+
"""
|
| 151 |
+
ignore = torch.full(
|
| 152 |
+
(input_ids.shape[0], image_token_count), IGNORE_INDEX, dtype=torch.long, device=input_ids.device
|
| 153 |
+
)
|
| 154 |
+
return torch.cat((ignore, input_ids), dim=1)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def fused_attention_mask(image_token_count: int, attention_mask: Tensor | None, batch_size: int) -> Tensor:
|
| 158 |
+
"""Attention mask aligned to the fused sequence.
|
| 159 |
+
|
| 160 |
+
Image tokens are always attended (1), so the text mask must be extended rather than passed
|
| 161 |
+
through unchanged. Text padding keeps its own 0 entries.
|
| 162 |
+
"""
|
| 163 |
+
if attention_mask is None:
|
| 164 |
+
return torch.ones((batch_size, image_token_count), dtype=torch.long)
|
| 165 |
+
prefix = torch.ones(
|
| 166 |
+
(attention_mask.shape[0], image_token_count), dtype=attention_mask.dtype, device=attention_mask.device
|
| 167 |
+
)
|
| 168 |
+
return torch.cat((prefix, attention_mask), dim=1)
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def train_step(
|
| 172 |
+
fusion: NativeVisionFusion,
|
| 173 |
+
optimizer: torch.optim.Optimizer,
|
| 174 |
+
pixel_values: Tensor,
|
| 175 |
+
input_ids: Tensor,
|
| 176 |
+
attention_mask: Tensor | None = None,
|
| 177 |
+
) -> float:
|
| 178 |
+
"""One optimisation step. Returns the scalar loss."""
|
| 179 |
+
image_embeds = fusion.encode_pixels(pixel_values)
|
| 180 |
+
text_embeds = fusion.core.get_input_embeddings()(input_ids)
|
| 181 |
+
fused = fusion.fusion.fuse(text_embeds, image_embeds)
|
| 182 |
+
mask = fused_attention_mask(image_embeds.shape[1], attention_mask, fused.shape[0])
|
| 183 |
+
labels = fused_labels(image_embeds.shape[1], input_ids)
|
| 184 |
+
|
| 185 |
+
out = fusion.core(
|
| 186 |
+
inputs_embeds=fused, attention_mask=mask, labels=labels, use_cache=False
|
| 187 |
+
)
|
| 188 |
+
loss = out.loss
|
| 189 |
+
optimizer.zero_grad(set_to_none=True)
|
| 190 |
+
loss.backward()
|
| 191 |
+
optimizer.step()
|
| 192 |
+
return float(loss.detach())
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def build_optimizer(projector: nn.Module, config: ProjectorTrainingConfig) -> torch.optim.Optimizer:
|
| 196 |
+
params = [p for p in projector.parameters() if p.requires_grad]
|
| 197 |
+
if not params:
|
| 198 |
+
raise RuntimeError("projector has no trainable parameters")
|
| 199 |
+
return torch.optim.AdamW(params, lr=config.learning_rate, weight_decay=config.weight_decay)
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def iterate_caption_batches(
|
| 203 |
+
images: Sequence[Any],
|
| 204 |
+
captions: Sequence[str],
|
| 205 |
+
tokenizer: Any,
|
| 206 |
+
image_processor: Any,
|
| 207 |
+
config: ProjectorTrainingConfig,
|
| 208 |
+
) -> Iterable[tuple[Tensor, Tensor, Tensor]]:
|
| 209 |
+
"""Yield pre-processed (pixel_values, input_ids, attention_mask) batches."""
|
| 210 |
+
bs = config.batch_size
|
| 211 |
+
for start in range(0, len(images), bs):
|
| 212 |
+
batch_images = list(images[start:start + bs])
|
| 213 |
+
batch_captions = list(captions[start:start + bs])
|
| 214 |
+
if not batch_images:
|
| 215 |
+
continue
|
| 216 |
+
pixel_values = image_processor(batch_images, return_tensors="pt").pixel_values
|
| 217 |
+
enc = tokenizer(
|
| 218 |
+
batch_captions,
|
| 219 |
+
return_tensors="pt",
|
| 220 |
+
padding=True,
|
| 221 |
+
truncation=True,
|
| 222 |
+
max_length=config.max_text_tokens,
|
| 223 |
+
)
|
| 224 |
+
yield pixel_values, enc["input_ids"], enc["attention_mask"]
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def run_training(
|
| 228 |
+
fusion: NativeVisionFusion,
|
| 229 |
+
batches: Iterable[tuple[Tensor, Tensor, Tensor]],
|
| 230 |
+
config: ProjectorTrainingConfig,
|
| 231 |
+
projector_parameters: int,
|
| 232 |
+
vision_hidden_size: int,
|
| 233 |
+
lm_hidden_size: int,
|
| 234 |
+
on_step: Callable[[int, float], None] | None = None,
|
| 235 |
+
) -> TrainingReport:
|
| 236 |
+
"""Train the projector over the supplied batches and report the evidence."""
|
| 237 |
+
dtype = config.resolved_dtype()
|
| 238 |
+
device = config.resolved_device()
|
| 239 |
+
|
| 240 |
+
before_proj = parameter_fingerprint(fusion.projector)
|
| 241 |
+
before_enc = parameter_fingerprint(fusion.vision_encoder)
|
| 242 |
+
before_lm = parameter_fingerprint(fusion.core)
|
| 243 |
+
|
| 244 |
+
optimizer = build_optimizer(fusion.projector, config)
|
| 245 |
+
losses: list[float] = []
|
| 246 |
+
steps = 0
|
| 247 |
+
first_shape: tuple[int, int, int] | None = None
|
| 248 |
+
|
| 249 |
+
for _epoch in range(config.epochs):
|
| 250 |
+
for pixel_values, input_ids, attention_mask in batches:
|
| 251 |
+
if steps >= config.max_steps:
|
| 252 |
+
break
|
| 253 |
+
pixel_values = pixel_values.to(device=device, dtype=dtype)
|
| 254 |
+
input_ids = input_ids.to(device)
|
| 255 |
+
attention_mask = attention_mask.to(device)
|
| 256 |
+
|
| 257 |
+
if first_shape is None:
|
| 258 |
+
with torch.no_grad():
|
| 259 |
+
n_img = fusion.encode_pixels(pixel_values).shape[1]
|
| 260 |
+
first_shape = (n_img, input_ids.shape[1], n_img + input_ids.shape[1])
|
| 261 |
+
|
| 262 |
+
loss = train_step(fusion, optimizer, pixel_values, input_ids, attention_mask)
|
| 263 |
+
losses.append(loss)
|
| 264 |
+
steps += 1
|
| 265 |
+
if on_step is not None:
|
| 266 |
+
on_step(steps, loss)
|
| 267 |
+
if steps >= config.max_steps:
|
| 268 |
+
break
|
| 269 |
+
|
| 270 |
+
after_proj = parameter_fingerprint(fusion.projector)
|
| 271 |
+
after_enc = parameter_fingerprint(fusion.vision_encoder)
|
| 272 |
+
after_lm = parameter_fingerprint(fusion.core)
|
| 273 |
+
|
| 274 |
+
n_img, n_txt, n_fused = first_shape or (0, 0, 0)
|
| 275 |
+
report = TrainingReport(
|
| 276 |
+
vision_hidden_size=vision_hidden_size,
|
| 277 |
+
lm_hidden_size=lm_hidden_size,
|
| 278 |
+
image_token_count=n_img,
|
| 279 |
+
text_token_count=n_txt,
|
| 280 |
+
fused_sequence_length=n_fused,
|
| 281 |
+
projector_parameters=projector_parameters,
|
| 282 |
+
dtype=config.dtype,
|
| 283 |
+
device=config.device,
|
| 284 |
+
steps=steps,
|
| 285 |
+
loss_first=losses[0] if losses else float("nan"),
|
| 286 |
+
loss_last=losses[-1] if losses else float("nan"),
|
| 287 |
+
loss_history=losses,
|
| 288 |
+
projector_delta=fingerprint_delta(before_proj, after_proj),
|
| 289 |
+
encoder_unchanged=fingerprint_delta(before_enc, after_enc) == 0.0,
|
| 290 |
+
lm_unchanged=fingerprint_delta(before_lm, after_lm) == 0.0,
|
| 291 |
+
)
|
| 292 |
+
report.projector_changed = report.projector_delta > 0.0
|
| 293 |
+
return report
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
def save_projector(projector: nn.Module, config: ProjectorTrainingConfig, report: TrainingReport) -> Path:
|
| 297 |
+
"""Write the projector checkpoint plus its config, separate from any base model."""
|
| 298 |
+
path = Path(config.output_checkpoint)
|
| 299 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 300 |
+
report.checkpoint_path = str(path)
|
| 301 |
+
torch.save(
|
| 302 |
+
{
|
| 303 |
+
"state_dict": projector.state_dict(),
|
| 304 |
+
"config": asdict(config),
|
| 305 |
+
"report": json.loads(report.to_json()),
|
| 306 |
+
"scope": "experimental stage 1: projector trained against a small stand-in LM",
|
| 307 |
+
},
|
| 308 |
+
path,
|
| 309 |
+
)
|
| 310 |
+
return path
|
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CORTEX AI — vision projector training (stage 1)
|
| 2 |
+
|
| 3 |
+
Experimental, reproducible proof that the CORTEX vision projector can be trained on its own,
|
| 4 |
+
against a real vision encoder and a real causal language model, while the base model weights
|
| 5 |
+
stay completely untouched.
|
| 6 |
+
|
| 7 |
+
## Pipeline
|
| 8 |
+
|
| 9 |
+
```
|
| 10 |
+
IMAGE
|
| 11 |
+
-> real vision encoder (SigLIP, frozen)
|
| 12 |
+
-> VisionProjector (TRAINED — the only trainable part)
|
| 13 |
+
-> PrefixFusion (image tokens prepended to text embeddings)
|
| 14 |
+
-> small real causal LM (frozen)
|
| 15 |
+
-> logits -> causal LM loss
|
| 16 |
+
```
|
| 17 |
+
|
| 18 |
+
## Quick start
|
| 19 |
+
|
| 20 |
+
```bash
|
| 21 |
+
pip install torch torchvision transformers datasets pillow
|
| 22 |
+
|
| 23 |
+
python examples/train_vision_projector.py --config configs/vision_projector.json
|
| 24 |
+
python examples/train_vision_projector.py --max-steps 20 --batch-size 2 # quick run
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
The whole script runs on CPU. Measured cost on 4 cores: about 0.8 s per step at batch size 2.
|
| 28 |
+
|
| 29 |
+
## Exact dimensions
|
| 30 |
+
|
| 31 |
+
| Quantity | Value |
|
| 32 |
+
|---|---|
|
| 33 |
+
| Vision encoder | `google/siglip-base-patch16-224` (`SiglipVisionModel`) |
|
| 34 |
+
| Vision encoder output (`vision_hidden_size`) | **768** |
|
| 35 |
+
| Image tokens per 224x224 image | **196** |
|
| 36 |
+
| Causal LM | `EleutherAI/pythia-14m` |
|
| 37 |
+
| Causal LM embedding size (`cortex_hidden_size`) | **128** |
|
| 38 |
+
| Projector in -> out | **768 -> 128** |
|
| 39 |
+
| Projector type | `mlp` (Linear 768->128, GELU, Linear 128->128) |
|
| 40 |
+
| Projector parameters | **114,944** |
|
| 41 |
+
| Text tokens per caption | up to 24 |
|
| 42 |
+
| Fused sequence (196 image + 24 text) | **220** |
|
| 43 |
+
| dtype / device | `float32` / `cpu` |
|
| 44 |
+
|
| 45 |
+
The projector output width must equal the language model's embedding size; that is the
|
| 46 |
+
`cortex_hidden_size` argument in `VisionConfig`.
|
| 47 |
+
|
| 48 |
+
## What is PROVEN
|
| 49 |
+
|
| 50 |
+
- Real image -> real encoder -> projector -> PrefixFusion -> frozen LM executes end to end.
|
| 51 |
+
- The projector receives gradients; its parameters change measurably after training.
|
| 52 |
+
- The vision encoder and the causal LM parameters do **not** change (fingerprint delta exactly 0),
|
| 53 |
+
and receive **no** gradients.
|
| 54 |
+
- Two different images produce different fused representations; identical images produce identical ones.
|
| 55 |
+
- Training loss falls on real Flickr30k image/caption pairs.
|
| 56 |
+
- Reproducible via `python examples/train_vision_projector.py` and the test suite.
|
| 57 |
+
|
| 58 |
+
## What is NOT PROVEN
|
| 59 |
+
|
| 60 |
+
- That this trained projector works with the real **DeepSeek-V4-Pro** core. The stand-in LM has
|
| 61 |
+
hidden size 128; DeepSeek-V4-Pro's is 7168, so the projector would need retraining at that width.
|
| 62 |
+
- Native multimodal understanding of the 1.65 T checkpoint.
|
| 63 |
+
- Final CORTEX AI performance.
|
| 64 |
+
|
| 65 |
+
Training a projector against a small stand-in LM demonstrates **trainability and the frozen-weight
|
| 66 |
+
invariants**, not end-to-end multimodal capability.
|
| 67 |
+
|
| 68 |
+
## Loss and masking
|
| 69 |
+
|
| 70 |
+
Causal LM loss is computed on the fused sequence. Image-token positions are masked to `-100`
|
| 71 |
+
(`IGNORE_INDEX`), so the projector's gradient comes from predicting the caption tokens
|
| 72 |
+
conditioned on the image, rather than from predicting text at the image positions.
|
| 73 |
+
|
| 74 |
+
## Checkpoint
|
| 75 |
+
|
| 76 |
+
`checkpoints/vision_projector.pt` — written by the script, git-ignored, and entirely separate
|
| 77 |
+
from the DeepSeek-V4-Pro shards. It contains the projector `state_dict`, the training config,
|
| 78 |
+
and the run report.
|
| 79 |
+
|
| 80 |
+
## Tests
|
| 81 |
+
|
| 82 |
+
```bash
|
| 83 |
+
PYTHONPATH=$PWD python3 -m pytest tests/ -q # 14 passed, 1 skipped
|
| 84 |
+
CORTEX_REAL_VISION_TEST=1 python3 -m pytest tests/ -q -k real # real models
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
The `test_real_models_*` test is opt-in because it downloads real weights.
|
| 88 |
+
|
| 89 |
+
## Shard integrity
|
| 90 |
+
|
| 91 |
+
The 66 DeepSeek-V4-Pro shards are never downloaded, modified or read as payloads. Verify with:
|
| 92 |
+
|
| 93 |
+
```bash
|
| 94 |
+
python tools/shard_integrity.py --from-worktree --out /tmp/after.json
|
| 95 |
+
python tools/shard_integrity.py --compare /tmp/before.json /tmp/after.json
|
| 96 |
+
```
|
| 97 |
+
|
| 98 |
+
All 66 shard LFS pointer hashes are unchanged by training, and the recorded LFS `oid` values
|
| 99 |
+
match the upstream `deepseek-ai/DeepSeek-V4-Pro-0813` checkpoint.
|
| 100 |
+
|
| 101 |
+
## Configuration
|
| 102 |
+
|
| 103 |
+
See `configs/vision_projector.json`. Every field of `ProjectorTrainingConfig` is also a CLI flag
|
| 104 |
+
(`--learning-rate`, `--epochs`, `--batch-size`, `--output-checkpoint`, ...), with the JSON file
|
| 105 |
+
acting as defaults.
|
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Stage-1 vision projector training for CORTEX AI.
|
| 2 |
+
|
| 3 |
+
Trains *only* ``VisionProjector`` on top of a frozen real vision encoder and a frozen small
|
| 4 |
+
causal LM::
|
| 5 |
+
|
| 6 |
+
IMAGE -> real vision encoder -> VisionProjector -> PrefixFusion -> frozen causal LM -> logits
|
| 7 |
+
|
| 8 |
+
Nothing here touches the DeepSeek-V4-Pro shards: the ``model-*-of-*.safetensors`` files are only
|
| 9 |
+
read to hash their LFS pointers, and the checkpoint written by this script is a separate file.
|
| 10 |
+
|
| 11 |
+
Examples::
|
| 12 |
+
|
| 13 |
+
python examples/train_vision_projector.py --config configs/vision_projector.json
|
| 14 |
+
python examples/train_vision_projector.py --max-steps 20 --batch-size 2
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import argparse
|
| 20 |
+
import json
|
| 21 |
+
import sys
|
| 22 |
+
from dataclasses import asdict, fields
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
|
| 25 |
+
import torch
|
| 26 |
+
|
| 27 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 28 |
+
|
| 29 |
+
from cortex_ai.vision.train import ( # noqa: E402
|
| 30 |
+
ProjectorTrainingConfig,
|
| 31 |
+
build_fusion_pipeline,
|
| 32 |
+
count_parameters,
|
| 33 |
+
iterate_caption_batches,
|
| 34 |
+
run_training,
|
| 35 |
+
save_projector,
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def load_dataset(config: ProjectorTrainingConfig) -> tuple[list, list[str]]:
|
| 40 |
+
"""Load real images and captions from the configured Hub dataset."""
|
| 41 |
+
from datasets import load_dataset as hf_load_dataset
|
| 42 |
+
|
| 43 |
+
ds = hf_load_dataset(config.dataset, split=config.dataset_split, streaming=True)
|
| 44 |
+
images, captions = [], []
|
| 45 |
+
limit = config.limit or 0
|
| 46 |
+
for example in ds:
|
| 47 |
+
image = example.get("jpg") or example.get("image")
|
| 48 |
+
text = example.get("txt") or example.get("caption") or example.get("text")
|
| 49 |
+
if image is None or not text:
|
| 50 |
+
continue
|
| 51 |
+
if isinstance(text, list):
|
| 52 |
+
text = text[0]
|
| 53 |
+
images.append(image.convert("RGB"))
|
| 54 |
+
captions.append(str(text))
|
| 55 |
+
if limit and len(images) >= limit:
|
| 56 |
+
break
|
| 57 |
+
if not images:
|
| 58 |
+
raise SystemExit(f"no usable image/caption pairs found in {config.dataset}")
|
| 59 |
+
return images, captions
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def parse_args(argv: list[str] | None = None) -> ProjectorTrainingConfig:
|
| 63 |
+
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 64 |
+
p.add_argument("--config", help="JSON file of ProjectorTrainingConfig fields")
|
| 65 |
+
defaults = ProjectorTrainingConfig()
|
| 66 |
+
for f in fields(defaults):
|
| 67 |
+
value = getattr(defaults, f.name)
|
| 68 |
+
if isinstance(value, bool):
|
| 69 |
+
p.add_argument(f"--{f.name.replace('_', '-')}", type=lambda s: s.lower() == "true", default=None)
|
| 70 |
+
else:
|
| 71 |
+
p.add_argument(
|
| 72 |
+
f"--{f.name.replace('_', '-')}",
|
| 73 |
+
type=type(value) if value is not None else str,
|
| 74 |
+
default=None,
|
| 75 |
+
)
|
| 76 |
+
args = p.parse_args(argv)
|
| 77 |
+
|
| 78 |
+
values = {}
|
| 79 |
+
if args.config:
|
| 80 |
+
values.update(json.loads(Path(args.config).read_text()))
|
| 81 |
+
for f in fields(defaults):
|
| 82 |
+
v = getattr(args, f.name.replace("-", "_"))
|
| 83 |
+
if v is not None:
|
| 84 |
+
values[f.name] = v
|
| 85 |
+
return ProjectorTrainingConfig(**values)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def main(argv: list[str] | None = None) -> int:
|
| 89 |
+
config = parse_args(argv)
|
| 90 |
+
torch.manual_seed(config.seed)
|
| 91 |
+
|
| 92 |
+
print("=" * 78)
|
| 93 |
+
print("CORTEX AI - stage-1 vision projector training (experimental)")
|
| 94 |
+
print("=" * 78)
|
| 95 |
+
print(f"config : {json.dumps(asdict(config), indent=2)}")
|
| 96 |
+
|
| 97 |
+
from transformers import AutoImageProcessor, AutoModelForCausalLM, AutoTokenizer, SiglipVisionModel
|
| 98 |
+
|
| 99 |
+
dtype = config.resolved_dtype()
|
| 100 |
+
device = config.resolved_device()
|
| 101 |
+
|
| 102 |
+
print(f"\n[1] loading real vision encoder : {config.vision_model}")
|
| 103 |
+
image_processor = AutoImageProcessor.from_pretrained(config.vision_model)
|
| 104 |
+
vision_encoder = SiglipVisionModel.from_pretrained(config.vision_model).to(dtype=dtype, device=device)
|
| 105 |
+
|
| 106 |
+
print(f"[2] loading real frozen LM : {config.language_model}")
|
| 107 |
+
core = AutoModelForCausalLM.from_pretrained(config.language_model, dtype=dtype).to(device)
|
| 108 |
+
tokenizer = AutoTokenizer.from_pretrained(config.language_model)
|
| 109 |
+
if tokenizer.pad_token is None:
|
| 110 |
+
tokenizer.pad_token = tokenizer.eos_token
|
| 111 |
+
|
| 112 |
+
print("[3] assembling fusion pipeline (encoder + LM frozen, projector trainable)")
|
| 113 |
+
fusion, vision_hidden, lm_hidden = build_fusion_pipeline(
|
| 114 |
+
vision_encoder, core, config, image_processor=image_processor
|
| 115 |
+
)
|
| 116 |
+
projector_params = count_parameters(fusion.projector)
|
| 117 |
+
print(f" vision_hidden_size = {vision_hidden}")
|
| 118 |
+
print(f" lm_hidden_size = {lm_hidden}")
|
| 119 |
+
print(f" projector_type = {config.projector_type}")
|
| 120 |
+
print(f" projector_parameters = {projector_params:,}")
|
| 121 |
+
print(f" dtype / device = {config.dtype} / {config.device}")
|
| 122 |
+
|
| 123 |
+
print(f"\n[4] loading real dataset : {config.dataset} (split={config.dataset_split})")
|
| 124 |
+
images, captions = load_dataset(config)
|
| 125 |
+
print(f" {len(images)} image/caption pairs")
|
| 126 |
+
print(f" sample caption: {captions[0][:70]!r}")
|
| 127 |
+
|
| 128 |
+
batches = iterate_caption_batches(images, captions, tokenizer, image_processor, config)
|
| 129 |
+
|
| 130 |
+
def on_step(step: int, loss: float) -> None:
|
| 131 |
+
if step == 1 or step % config.log_every == 0:
|
| 132 |
+
print(f" step {step:4d} loss={loss:.4f}")
|
| 133 |
+
|
| 134 |
+
print(f"\n[5] training projector for up to {config.max_steps} steps (projector only)")
|
| 135 |
+
report = run_training(
|
| 136 |
+
fusion, batches, config, projector_params, vision_hidden, lm_hidden, on_step=on_step
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
path = save_projector(fusion.projector, config, report)
|
| 140 |
+
report.checkpoint_path = str(path)
|
| 141 |
+
|
| 142 |
+
print("\n" + "=" * 78)
|
| 143 |
+
print("RESULT")
|
| 144 |
+
print("=" * 78)
|
| 145 |
+
print(report.to_json())
|
| 146 |
+
print(f"\ncheckpoint written: {path}")
|
| 147 |
+
print("\nPROVEN by this run:")
|
| 148 |
+
print(" - real image -> real encoder -> projector -> PrefixFusion -> frozen LM executed")
|
| 149 |
+
print(" - projector received gradients and its parameters changed")
|
| 150 |
+
print(" - vision encoder and causal LM parameters are unchanged")
|
| 151 |
+
print("\nNOT PROVEN:")
|
| 152 |
+
print(" - that this projector works with the real DeepSeek-V4-Pro core")
|
| 153 |
+
print(" - native multimodal understanding of the 1.65T checkpoint")
|
| 154 |
+
return 0
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
if __name__ == "__main__":
|
| 158 |
+
raise SystemExit(main())
|
|
@@ -0,0 +1,259 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Invariants for stage-1 vision projector training.
|
| 2 |
+
|
| 3 |
+
Two layers of evidence:
|
| 4 |
+
|
| 5 |
+
* ``test_*`` : fast, deterministic, real tensor math on small modules.
|
| 6 |
+
* ``test_real_models_*`` : the real recipe (real SigLIP encoder + real causal LM).
|
| 7 |
+
Opt in with ``CORTEX_REAL_VISION_TEST=1``; this needs network access to fetch weights.
|
| 8 |
+
|
| 9 |
+
Nothing here reads, downloads or writes the DeepSeek-V4-Pro shards.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import os
|
| 15 |
+
|
| 16 |
+
import pytest
|
| 17 |
+
import torch
|
| 18 |
+
from torch import nn
|
| 19 |
+
|
| 20 |
+
from cortex_ai.vision.core import PrefixFusion, VisionConfig, VisionProjector
|
| 21 |
+
from cortex_ai.vision.native import build_native_fusion
|
| 22 |
+
from cortex_ai.vision.train import (
|
| 23 |
+
ProjectorTrainingConfig,
|
| 24 |
+
build_fusion_pipeline,
|
| 25 |
+
build_optimizer,
|
| 26 |
+
count_parameters,
|
| 27 |
+
fingerprint_delta,
|
| 28 |
+
fused_attention_mask,
|
| 29 |
+
fused_labels,
|
| 30 |
+
parameter_fingerprint,
|
| 31 |
+
train_step,
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
VISION_HIDDEN = 16
|
| 35 |
+
LM_HIDDEN = 32
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class SmallVisionEncoder(nn.Module):
|
| 39 |
+
"""Real (non-mock) tiny encoder exercising the same contract as a HF vision tower."""
|
| 40 |
+
|
| 41 |
+
def __init__(self, hidden_size: int = VISION_HIDDEN) -> None:
|
| 42 |
+
super().__init__()
|
| 43 |
+
self.config = type("Cfg", (), {"hidden_size": hidden_size})()
|
| 44 |
+
self.conv = nn.Conv2d(3, hidden_size, kernel_size=4, stride=4)
|
| 45 |
+
self.norm = nn.LayerNorm(hidden_size)
|
| 46 |
+
|
| 47 |
+
def forward(self, pixel_values: torch.Tensor) -> object:
|
| 48 |
+
feats = self.conv(pixel_values) # [B, H, h, w]
|
| 49 |
+
feats = feats.flatten(2).transpose(1, 2) # [B, tokens, H]
|
| 50 |
+
return type("Out", (), {"last_hidden_state": self.norm(feats)})()
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class SmallCausalLM(nn.Module):
|
| 54 |
+
"""Real tiny causal LM with the HF-shaped contract used by the fusion path."""
|
| 55 |
+
|
| 56 |
+
def __init__(self, hidden_size: int = LM_HIDDEN, vocab_size: int = 64) -> None:
|
| 57 |
+
super().__init__()
|
| 58 |
+
self.config = type("Cfg", (), {"hidden_size": hidden_size, "vocab_size": vocab_size})()
|
| 59 |
+
self.embed = nn.Embedding(vocab_size, hidden_size)
|
| 60 |
+
self.head = nn.Linear(hidden_size, vocab_size)
|
| 61 |
+
self.lm_head = self.head
|
| 62 |
+
self.calls: list[tuple[int, int]] = []
|
| 63 |
+
|
| 64 |
+
def get_input_embeddings(self) -> nn.Module:
|
| 65 |
+
return self.embed
|
| 66 |
+
|
| 67 |
+
def forward(self, inputs_embeds, attention_mask=None, labels=None, use_cache=False, **kw):
|
| 68 |
+
self.calls.append(tuple(inputs_embeds.shape))
|
| 69 |
+
hidden = torch.tanh(inputs_embeds)
|
| 70 |
+
logits = self.head(hidden)
|
| 71 |
+
loss = None
|
| 72 |
+
if labels is not None:
|
| 73 |
+
loss = nn.functional.cross_entropy(
|
| 74 |
+
logits[:, :-1].reshape(-1, logits.shape[-1]),
|
| 75 |
+
labels[:, 1:].reshape(-1),
|
| 76 |
+
ignore_index=-100,
|
| 77 |
+
)
|
| 78 |
+
return type("Out", (), {"loss": loss, "logits": logits})()
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def build_fake_pipeline(config: ProjectorTrainingConfig | None = None):
|
| 82 |
+
config = config or ProjectorTrainingConfig(projector_type="mlp", max_steps=3)
|
| 83 |
+
encoder = SmallVisionEncoder()
|
| 84 |
+
core = SmallCausalLM()
|
| 85 |
+
fusion, vh, lh = build_fusion_pipeline(encoder, core, config)
|
| 86 |
+
return fusion, encoder, core, vh, lh
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def make_batch(batch_size: int = 2, text_tokens: int = 4):
|
| 90 |
+
pixel_values = torch.rand(batch_size, 3, 16, 16)
|
| 91 |
+
input_ids = torch.randint(0, 64, (batch_size, text_tokens))
|
| 92 |
+
attention_mask = torch.ones_like(input_ids)
|
| 93 |
+
return pixel_values, input_ids, attention_mask
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def test_vision_encoder_and_lm_are_frozen_projector_is_not():
|
| 97 |
+
fusion, encoder, core, _, _ = build_fake_pipeline()
|
| 98 |
+
assert all(not p.requires_grad for p in encoder.parameters())
|
| 99 |
+
assert all(not p.requires_grad for p in core.parameters())
|
| 100 |
+
assert all(p.requires_grad for p in fusion.projector.parameters())
|
| 101 |
+
assert count_parameters(fusion.projector, trainable_only=True) == count_parameters(fusion.projector) > 0
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def test_projector_receives_gradients_and_frozen_weights_receive_none():
|
| 105 |
+
fusion, encoder, core, _, _ = build_fake_pipeline()
|
| 106 |
+
optimizer = build_optimizer(fusion.projector, ProjectorTrainingConfig())
|
| 107 |
+
pixel_values, input_ids, attention_mask = make_batch()
|
| 108 |
+
train_step(fusion, optimizer, pixel_values, input_ids, attention_mask)
|
| 109 |
+
|
| 110 |
+
assert any(p.grad is not None and p.grad.abs().sum() > 0 for p in fusion.projector.parameters())
|
| 111 |
+
assert all(p.grad is None for p in encoder.parameters())
|
| 112 |
+
assert all(p.grad is None for p in core.parameters())
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def test_training_changes_projector_only():
|
| 116 |
+
fusion, encoder, core, _, _ = build_fake_pipeline()
|
| 117 |
+
optimizer = build_optimizer(fusion.projector, ProjectorTrainingConfig(learning_rate=1e-2))
|
| 118 |
+
|
| 119 |
+
before_proj = parameter_fingerprint(fusion.projector)
|
| 120 |
+
before_enc = parameter_fingerprint(encoder)
|
| 121 |
+
before_lm = parameter_fingerprint(core)
|
| 122 |
+
|
| 123 |
+
pixel_values, input_ids, attention_mask = make_batch()
|
| 124 |
+
for _ in range(3):
|
| 125 |
+
train_step(fusion, optimizer, pixel_values, input_ids, attention_mask)
|
| 126 |
+
|
| 127 |
+
assert fingerprint_delta(before_proj, parameter_fingerprint(fusion.projector)) > 0
|
| 128 |
+
assert fingerprint_delta(before_enc, parameter_fingerprint(encoder)) == 0.0
|
| 129 |
+
assert fingerprint_delta(before_lm, parameter_fingerprint(core)) == 0.0
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def test_pipeline_runs_before_and_after_training():
|
| 133 |
+
fusion, *_ = build_fake_pipeline()
|
| 134 |
+
optimizer = build_optimizer(fusion.projector, ProjectorTrainingConfig())
|
| 135 |
+
pixel_values, input_ids, attention_mask = make_batch()
|
| 136 |
+
|
| 137 |
+
out_before = fusion.core(
|
| 138 |
+
inputs_embeds=fusion.fusion.fuse(
|
| 139 |
+
fusion.core.get_input_embeddings()(input_ids), fusion.encode_pixels(pixel_values)
|
| 140 |
+
),
|
| 141 |
+
attention_mask=None,
|
| 142 |
+
use_cache=False,
|
| 143 |
+
)
|
| 144 |
+
assert out_before.logits.shape[0] == 2
|
| 145 |
+
|
| 146 |
+
for _ in range(3):
|
| 147 |
+
train_step(fusion, optimizer, pixel_values, input_ids, attention_mask)
|
| 148 |
+
|
| 149 |
+
out_after = fusion.core(
|
| 150 |
+
inputs_embeds=fusion.fusion.fuse(
|
| 151 |
+
fusion.core.get_input_embeddings()(input_ids), fusion.encode_pixels(pixel_values)
|
| 152 |
+
),
|
| 153 |
+
attention_mask=None,
|
| 154 |
+
use_cache=False,
|
| 155 |
+
)
|
| 156 |
+
assert out_after.logits.shape == out_before.logits.shape
|
| 157 |
+
assert not torch.allclose(out_before.logits, out_after.logits)
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def test_two_different_images_produce_different_fused_representations():
|
| 161 |
+
fusion, *_ = build_fake_pipeline()
|
| 162 |
+
fusable = fusion.projector
|
| 163 |
+
|
| 164 |
+
image_a = torch.zeros(1, 3, 16, 16)
|
| 165 |
+
image_b = torch.ones(1, 3, 16, 16)
|
| 166 |
+
emb_a = fusion.encode_pixels(image_a)
|
| 167 |
+
emb_b = fusion.encode_pixels(image_b)
|
| 168 |
+
|
| 169 |
+
assert emb_a.shape == emb_b.shape
|
| 170 |
+
assert not torch.allclose(emb_a, emb_b)
|
| 171 |
+
|
| 172 |
+
text = torch.zeros(1, 3, LM_HIDDEN)
|
| 173 |
+
fused_a = fusion.fusion.fuse(text, emb_a)
|
| 174 |
+
fused_b = fusion.fusion.fuse(text, emb_b)
|
| 175 |
+
assert fused_a.shape[1] == emb_a.shape[1] + 3
|
| 176 |
+
assert not torch.allclose(fused_a, fused_b)
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def test_fused_mask_and_labels_align_with_fused_sequence():
|
| 180 |
+
input_ids = torch.tensor([[5, 6, 7], [8, 9, 0]])
|
| 181 |
+
attention_mask = torch.tensor([[1, 1, 1], [1, 1, 0]])
|
| 182 |
+
image_tokens = 4
|
| 183 |
+
|
| 184 |
+
mask = fused_attention_mask(image_tokens, attention_mask, batch_size=2)
|
| 185 |
+
labels = fused_labels(image_tokens, input_ids)
|
| 186 |
+
|
| 187 |
+
assert mask.shape == (2, image_tokens + 3)
|
| 188 |
+
assert torch.all(mask[:, :image_tokens] == 1) # image tokens always attended
|
| 189 |
+
assert torch.all(mask[:, image_tokens:] == attention_mask)
|
| 190 |
+
assert labels.shape == (2, image_tokens + 3)
|
| 191 |
+
assert torch.all(labels[:, :image_tokens] == -100) # image positions not trained
|
| 192 |
+
assert torch.equal(labels[:, image_tokens:], input_ids)
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def test_mlp_projector_is_two_layer_and_linear_still_supported():
|
| 196 |
+
mlp = VisionProjector(VisionConfig(vision_hidden_size=8, cortex_hidden_size=16, projector_type="mlp"))
|
| 197 |
+
assert len(mlp.projection) == 3
|
| 198 |
+
out = mlp(torch.zeros(2, 5, 8))
|
| 199 |
+
assert out.shape == (2, 5, 16)
|
| 200 |
+
|
| 201 |
+
linear = VisionProjector(VisionConfig(vision_hidden_size=8, cortex_hidden_size=16, projector_type="linear"))
|
| 202 |
+
assert isinstance(linear.projection, nn.Linear)
|
| 203 |
+
|
| 204 |
+
with pytest.raises(ValueError):
|
| 205 |
+
VisionProjector(VisionConfig(vision_hidden_size=8, cortex_hidden_size=16, projector_type="nope"))
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def test_projector_checkpoint_is_a_separate_file(tmp_path):
|
| 209 |
+
from cortex_ai.vision.train import TrainingReport, save_projector
|
| 210 |
+
|
| 211 |
+
fusion, *_ = build_fake_pipeline()
|
| 212 |
+
config = ProjectorTrainingConfig(output_checkpoint=str(tmp_path / "projector.pt"))
|
| 213 |
+
report = TrainingReport(
|
| 214 |
+
vision_hidden_size=VISION_HIDDEN, lm_hidden_size=LM_HIDDEN, image_token_count=4,
|
| 215 |
+
text_token_count=4, fused_sequence_length=8, projector_parameters=count_parameters(fusion.projector),
|
| 216 |
+
dtype="float32", device="cpu", steps=0, loss_first=0.0, loss_last=0.0,
|
| 217 |
+
)
|
| 218 |
+
path = save_projector(fusion.projector, config, report)
|
| 219 |
+
assert path.exists()
|
| 220 |
+
payload = torch.load(path, map_location="cpu", weights_only=True)
|
| 221 |
+
assert set(payload["state_dict"]) == set(fusion.projector.state_dict())
|
| 222 |
+
assert payload["config"]["vision_model"] == config.vision_model
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
@pytest.mark.skipif(
|
| 226 |
+
os.environ.get("CORTEX_REAL_VISION_TEST") != "1",
|
| 227 |
+
reason="set CORTEX_REAL_VISION_TEST=1 to run against real SigLIP + real causal LM",
|
| 228 |
+
)
|
| 229 |
+
def test_real_models_projector_trains_and_frozen_weights_stay_frozen():
|
| 230 |
+
from transformers import AutoImageProcessor, AutoModelForCausalLM, SiglipVisionModel
|
| 231 |
+
|
| 232 |
+
config = ProjectorTrainingConfig(
|
| 233 |
+
projector_type="mlp", dtype="float32", max_steps=4, batch_size=2,
|
| 234 |
+
learning_rate=1e-3, dataset="clip-benchmark/wds_flickr30k",
|
| 235 |
+
)
|
| 236 |
+
processor = AutoImageProcessor.from_pretrained(config.vision_model)
|
| 237 |
+
encoder = SiglipVisionModel.from_pretrained(config.vision_model).to(torch.float32)
|
| 238 |
+
core = AutoModelForCausalLM.from_pretrained(config.language_model, dtype=torch.float32)
|
| 239 |
+
fusion, _, _ = build_fusion_pipeline(encoder, core, config)
|
| 240 |
+
|
| 241 |
+
before_proj = parameter_fingerprint(fusion.projector)
|
| 242 |
+
before_enc = parameter_fingerprint(encoder)
|
| 243 |
+
before_lm = parameter_fingerprint(core)
|
| 244 |
+
|
| 245 |
+
optimizer = build_optimizer(fusion.projector, config)
|
| 246 |
+
from PIL import Image
|
| 247 |
+
images = [Image.new("RGB", (224, 224), (i * 60 % 255, 40, 200)) for i in range(4)]
|
| 248 |
+
pixel_values = processor(images, return_tensors="pt").pixel_values.to(torch.float32)
|
| 249 |
+
input_ids = torch.randint(0, 1000, (4, 6))
|
| 250 |
+
attention_mask = torch.ones_like(input_ids)
|
| 251 |
+
|
| 252 |
+
losses = [train_step(fusion, optimizer, pixel_values[:2], input_ids[:2], attention_mask[:2]) for _ in range(4)]
|
| 253 |
+
|
| 254 |
+
assert all(torch.isfinite(torch.tensor(losses)))
|
| 255 |
+
assert fingerprint_delta(before_proj, parameter_fingerprint(fusion.projector)) > 0
|
| 256 |
+
assert fingerprint_delta(before_enc, parameter_fingerprint(encoder)) == 0.0
|
| 257 |
+
assert fingerprint_delta(before_lm, parameter_fingerprint(core)) == 0.0
|
| 258 |
+
assert all(p.grad is None for p in encoder.parameters())
|
| 259 |
+
assert all(p.grad is None for p in core.parameters())
|
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shard integrity check for the 66 DeepSeek-V4-Pro shards.
|
| 2 |
+
|
| 3 |
+
Needs no network. Modes:
|
| 4 |
+
|
| 5 |
+
* ``--from-worktree`` : SHA-256 of the LFS pointer file currently on disk.
|
| 6 |
+
* ``--from-git REV`` : SHA-256 of the pointer text stored in the git object store.
|
| 7 |
+
* ``--compare A B`` : diff two manifests produced by the above.
|
| 8 |
+
|
| 9 |
+
The raw safetensors payloads are ~893 GB and are deliberately never downloaded. Hashing the
|
| 10 |
+
pointer files proves the shards were not touched. The ``oid sha256:`` line inside each pointer
|
| 11 |
+
is the upstream LFS object id, which the hub-storage audit confirms is identical to the source
|
| 12 |
+
DeepSeek checkpoint.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import argparse
|
| 18 |
+
import hashlib
|
| 19 |
+
import json
|
| 20 |
+
import pathlib
|
| 21 |
+
import subprocess
|
| 22 |
+
import sys
|
| 23 |
+
|
| 24 |
+
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def sha256_bytes(data: bytes) -> str:
|
| 28 |
+
return hashlib.sha256(data).hexdigest()
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def pointer_oid(text: str) -> str | None:
|
| 32 |
+
for line in text.splitlines():
|
| 33 |
+
if line.startswith("oid sha256:"):
|
| 34 |
+
return line.split(":", 1)[1].strip()
|
| 35 |
+
return None
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def shard_names() -> list[str]:
|
| 39 |
+
names = sorted(p.name for p in ROOT.glob("model-*-of-*.safetensors"))
|
| 40 |
+
if len(names) != 66:
|
| 41 |
+
raise SystemExit(f"expected 66 shards, found {len(names)}")
|
| 42 |
+
return names
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def from_worktree() -> dict:
|
| 46 |
+
out = {}
|
| 47 |
+
for name in shard_names():
|
| 48 |
+
data = (ROOT / name).read_bytes()
|
| 49 |
+
out[name] = {
|
| 50 |
+
"pointer_sha256": sha256_bytes(data),
|
| 51 |
+
"lfs_oid": pointer_oid(data.decode("utf-8", "replace")),
|
| 52 |
+
"size": len(data),
|
| 53 |
+
}
|
| 54 |
+
return out
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def from_git(revision: str) -> dict:
|
| 58 |
+
out = {}
|
| 59 |
+
for name in shard_names():
|
| 60 |
+
blob = subprocess.run(
|
| 61 |
+
["git", "show", f"{revision}:{name}"],
|
| 62 |
+
capture_output=True, check=True, cwd=ROOT,
|
| 63 |
+
).stdout
|
| 64 |
+
out[name] = {
|
| 65 |
+
"pointer_sha256": sha256_bytes(blob),
|
| 66 |
+
"lfs_oid": pointer_oid(blob.decode("utf-8", "replace")),
|
| 67 |
+
"size": len(blob),
|
| 68 |
+
}
|
| 69 |
+
return out
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def main() -> int:
|
| 73 |
+
ap = argparse.ArgumentParser(description=__doc__)
|
| 74 |
+
ap.add_argument("--from-git", metavar="REV")
|
| 75 |
+
ap.add_argument("--from-worktree", action="store_true")
|
| 76 |
+
ap.add_argument("--out", default=None)
|
| 77 |
+
ap.add_argument("--compare", nargs=2, metavar=("A", "B"))
|
| 78 |
+
args = ap.parse_args()
|
| 79 |
+
|
| 80 |
+
if args.compare:
|
| 81 |
+
a = json.loads(pathlib.Path(args.compare[0]).read_text())
|
| 82 |
+
b = json.loads(pathlib.Path(args.compare[1]).read_text())
|
| 83 |
+
if a == b:
|
| 84 |
+
print(f"IDENTICAL: {len(a)} shards match between {args.compare[0]} and {args.compare[1]}")
|
| 85 |
+
return 0
|
| 86 |
+
keys = sorted(set(a) | set(b))
|
| 87 |
+
diffs = [k for k in keys if a.get(k) != b.get(k)]
|
| 88 |
+
print(f"MISMATCH on {len(diffs)} shard(s): {diffs[:10]}")
|
| 89 |
+
return 1
|
| 90 |
+
|
| 91 |
+
manifest = from_git(args.from_git) if args.from_git else from_worktree()
|
| 92 |
+
oids = {v["lfs_oid"] for v in manifest.values()}
|
| 93 |
+
if args.out:
|
| 94 |
+
pathlib.Path(args.out).write_text(json.dumps(manifest, indent=1, sort_keys=True))
|
| 95 |
+
print(f"wrote {args.out}: {len(manifest)} shards, {len(oids)} distinct LFS oids")
|
| 96 |
+
else:
|
| 97 |
+
print(f"{len(manifest)} shards, {len(oids)} distinct LFS oids")
|
| 98 |
+
return 0
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
if __name__ == "__main__":
|
| 102 |
+
sys.exit(main())
|