zhangrenchao commited on
Commit
53becf5
·
verified ·
1 Parent(s): 60c2561

Add engineering reproduction package

Browse files
.gitattributes CHANGED
@@ -1,35 +1,4 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
  *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
  *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
1
  *.ckpt filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  *.pt filter=lfs diff=lfs merge=lfs -text
3
+ *.npz filter=lfs diff=lfs merge=lfs -text
4
+ *.npy filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
README.md ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ language:
4
+ - en
5
+ tags:
6
+ - OneScience
7
+ - Earth Science
8
+ - Multi-Source Earth Observation
9
+ - Remote Sensing Foundation Model
10
+ frameworks: PyTorch
11
+ ---
12
+
13
+ <p align="center">
14
+ <strong>
15
+ <span style="font-size: 30px;">Clay Foundation Model</span>
16
+ </strong>
17
+ </p>
18
+
19
+ # Model Introduction
20
+
21
+ Clay Foundation Model is an independent engineering reproduction of the public Clay v1.5 specification. It jointly encodes satellite imagery from different sensors, band center wavelengths, ground sample distance, time, and latitude/longitude into Earth observation embeddings, and reconstructs input bands with a Masked Autoencoder. It can serve as a representation backbone for land-cover classification, regression, change detection, and other downstream remote-sensing tasks.
22
+
23
+ Official documentation: Clay Foundation Model v1.5
24
+ https://clay-foundation.github.io/model/release-notes/specification.html
25
+
26
+ # Model Description
27
+
28
+ Clay Foundation Model was proposed by the Clay Foundation. The model was trained on multi-sensor Earth observation data including Sentinel-2, Landsat, Sentinel-1, NAIP, LINZ, and MODIS. It is suitable for remote-sensing image representation, multispectral reconstruction, land-cover classification, regression, change detection, and related tasks.
29
+
30
+ # Use Cases
31
+
32
+ | Use Case | Description |
33
+ | :---: | :--- |
34
+ | Multi-sensor representation | Use one model to process imagery with different numbers of bands from sensors such as Sentinel-2, Landsat, and Sentinel-1. |
35
+ | Dynamic spectral encoding | Dynamically generate Patch Embeddings from the center wavelengths of input bands to validate integration methods for new sensors. |
36
+ | Remote-sensing image reconstruction | Use a Masked Autoencoder to reconstruct masked patches in multispectral imagery. |
37
+ | Local engineering validation | Use a small amount of synthetic data to validate training, inference, evaluation, visualization, and checkpoint workflows. |
38
+ | Multi-GPU training | Launch distributed data-parallel training with `torchrun`. |
39
+
40
+ # Usage Instructions
41
+
42
+ ## 1.OneCode
43
+
44
+ Experience intelligent, one-click AI4S programming through the OneCode online environment:
45
+
46
+ [Try intelligent, one-click AI4S programming](https://web-2069360198568017922-iaaj.ksai.scnet.cn:58043/home)
47
+
48
+ ## 2.Download and Installation
49
+
50
+ ```bash
51
+ hf download OneScience-Group/ClayFoundation --local-dir ./ClayFoundation
52
+ cd ClayFoundation
53
+ ```
54
+
55
+ ### Environment Dependencies
56
+
57
+ **Hardware Requirements**
58
+
59
+ - A GPU or DCU is recommended.
60
+ - A CPU can be used to verify connectivity with the default small-sample configuration; training the official-size model requires large-scale acceleration resources.
61
+ - DCU users must install DTK in advance. DTK 25.04.2 or later, or the OneScience-recommended version matching the current cluster, is recommended.
62
+
63
+ **DCU Environment**
64
+
65
+ ```bash
66
+ # Activate DTK and CONDA first
67
+ conda create -n onescience311 python=3.11 -y
68
+ conda activate onescience311
69
+ # uv installation is supported
70
+ pip install onescience[earth-dcu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
71
+ ```
72
+
73
+ **GPU Environment**
74
+
75
+ ```bash
76
+ # Activate CONDA first
77
+ conda create -n onescience311 python=3.11 -y libstdcxx-ng=12 libgcc-ng=12 gcc_linux-64=12 gxx_linux-64=12
78
+ conda activate onescience311
79
+ # uv installation is supported
80
+ pip install onescience[earth-gpu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
81
+ ```
82
+
83
+ ### Training Data
84
+
85
+ This repository uses a small number of synthetic samples to validate the engineering workflow. The training and test data are stored in `data/train.npz` and `data/test.npz`, respectively. The synthetic data contains 10-band Sentinel-2, 6-band Landsat, and 2-band Sentinel-1 imagery, with center wavelengths and ground sample distances corresponding to the official metadata for each sensor. Each sample also includes week, hour, latitude, and longitude metadata; a synthetic teacher representation target; and classification and regression targets for lightweight evaluation.
86
+
87
+ The default input size is 64×64, with four training samples and two test samples. This scale is intended only to validate the dynamic-band interface, spatiotemporal metadata encoding, MAE training, and multi-sensor inference workflow. It does not represent the data distribution or training scale of the approximately 70 million global remote-sensing chips used by the official model.
88
+
89
+ ```bash
90
+ python scripts/fake_data.py
91
+ ```
92
+
93
+ ### Training
94
+
95
+ ```bash
96
+ python scripts/train.py
97
+ ```
98
+
99
+ For multi-GPU training, use:
100
+
101
+ ```bash
102
+ torchrun --nproc_per_node=8 --nnodes=1 --rdzv_id=1000 --rdzv_backend=c10d --max_restarts=0 --master_addr="localhost" --master_port=29500 scripts/train.py
103
+ ```
104
+
105
+ The default configuration is intended for rapid workflow validation. Formal experiments should use real remote-sensing data, the official-size configuration, a DINOv2 teacher, and a complete training schedule.
106
+
107
+ ```text
108
+ result/checkpoints/clayfoundation.pt
109
+ result/training/metrics.json
110
+ ```
111
+
112
+ ### Trained Weights
113
+
114
+ This repository does not include synthetic or official weights in `weight/`. The Clay Foundation has released the official Clay v1.5 checkpoint:
115
+
116
+ https://huggingface.co/made-with-clay/Clay/resolve/main/v1.5/clay-v1.5.ckpt
117
+
118
+ This repository is a scaled-down independent engineering implementation. Its model parameter names and dimensions are incompatible with the official Encoder weights of approximately 1.25 GB. To use the official weights, use the model implementation and data preprocessing workflow provided by the official Clay repository.
119
+
120
+ ### Inference
121
+
122
+ ```bash
123
+ python scripts/inference.py
124
+ ```
125
+
126
+ Inference loads the training checkpoint and separately processes Sentinel-2, Landsat, and Sentinel-1 test imagery. It generates Encoder embeddings, L2-normalized projected embeddings, complete multispectral MAE reconstructions, reconstruction loss, and teacher representation alignment loss, and saves them to:
127
+
128
+ ```text
129
+ result/output/predictions.npz
130
+ ```
131
+
132
+ ### Evaluation and Visualization
133
+
134
+ ```bash
135
+ python scripts/result.py
136
+ ```
137
+
138
+ The evaluation measures image reconstruction and representation quality across sensors and reports reconstruction error, embedding norms, and representation similarity. It also generates comparison plots of the input imagery, reconstructed results, and absolute errors. Results on synthetic data are intended only to validate the engineering workflow and do not represent the official Clay v1.5 training loss, real downstream-task performance, or cross-region generalization capability.
139
+
140
+ ```text
141
+ result/evaluation/metrics.json
142
+ result/evaluation/comparison.png
143
+ ```
144
+
145
+ # Official OneScience Information
146
+
147
+ | Platform | OneScience Main Repository | Skills Repository |
148
+ | --- | --- | --- |
149
+ | Gitee | https://gitee.com/onescience-ai/onescience | https://gitee.com/onescience-ai/oneskills |
150
+ | GitHub | https://github.com/onescience-ai/OneScience | https://github.com/onescience-ai/oneskills |
151
+
152
+ # Citation and License
153
+
154
+ This repository is an independent engineering reproduction of the public Clay Foundation Model v1.5 specification. The official Clay source code and model weights are licensed under Apache-2.0.
155
+
156
+ Official Clay repository:
157
+ https://github.com/Clay-foundation/model
158
+
159
+ Use of this repository's code, the official model weights, and the data remains subject to the licenses and terms of use of their respective projects.
README_zh.md ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: Apache-2.0
3
+ language:
4
+ - en
5
+ - zh
6
+ tags:
7
+ - OneScience
8
+ - 地球科学
9
+ - 多源地球观测
10
+ - 遥感基础模型
11
+ frameworks: PyTorch
12
+ datasets:
13
+ - Synthetic Multi-Sensor Earth Observation Chips
14
+ ---
15
+
16
+ <p align="center">
17
+ <strong>
18
+ <span style="font-size: 30px;">Clay Foundation Model</span>
19
+ </strong>
20
+ </p>
21
+
22
+ # 模型介绍
23
+
24
+ Clay Foundation Model 是 Clay v1.5 公开规格的独立工程复现模型,将不同传感器的卫星影像、波段中心波长、地面采样距离、时间和经纬度统一编码为地球观测 embedding,并通过 Masked Autoencoder 重建输入波段,可作为地物分类、回归、变化检测和其他下游遥感任务的表征骨干网络。
25
+
26
+ 官方文档:Clay Foundation Model v1.5
27
+ https://clay-foundation.github.io/model/release-notes/specification.html
28
+
29
+ # 模型描述
30
+
31
+ Clay Foundation Model 由 Clay Foundation 提出。模型使用 Sentinel-2、Landsat、Sentinel-1、NAIP、LINZ 和 MODIS 等多传感器地球观测数据训练。模型适用于遥感影像表征、多波段重建、地物分类、回归和变化检测等任务。
32
+
33
+ # 适用场景
34
+
35
+ | 场景 | 说明 |
36
+ | :---: | :--- |
37
+ | 多传感器表征 | 使用同一模型处理 Sentinel-2、Landsat 和 Sentinel-1 等不同波段数量的影像。 |
38
+ | 动态光谱编码 | 根据输入波段的中心波长动态生成 Patch Embedding,验证新传感器接入方式。 |
39
+ | 遥感影像重建 | 使用 Masked Autoencoder 对被遮挡的多波段影像 Patch 进行重建。 |
40
+ | 本地工程验证 | 使用少量虚拟数据检查训练、推理、评估、可视化和 checkpoint 流程。 |
41
+ | 多卡训练 | 通过 `torchrun` 启动分布式数据并行训练。 |
42
+
43
+ # 使用说明
44
+
45
+ ## 1.OneCode
46
+
47
+ 可通过 OneCode 在线环境体验智能化一键式 AI4S 编程:
48
+
49
+ [点击体验智能化一键式 AI4S 编程](https://web-2069360198568017922-iaaj.ksai.scnet.cn:58043/home)
50
+
51
+ ## 2.下载安装
52
+
53
+ ```bash
54
+ modelscope download --model OneScience/ClayFoundation --local_dir ./ClayFoundation
55
+ cd ClayFoundation
56
+ ```
57
+
58
+ ### 环境依赖
59
+
60
+ **硬件要求**
61
+
62
+ - 推荐使用 GPU 或 DCU 运行。
63
+ - CPU 可用于默认小样本配置的连通性验证,官方尺寸模型训练需要大规模加速资源。
64
+ - DCU 用户需预先安装 DTK,建议使用 DTK 25.04.2 以上版本或与当前集群匹配的 OneScience 推荐版本。
65
+
66
+ **DCU环境**
67
+
68
+ ```bash
69
+ # 请首先激活DTK及CONDA
70
+ conda create -n onescience311 python=3.11 -y
71
+ conda activate onescience311
72
+ # 支持uv安装
73
+ pip install onescience[earth-dcu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
74
+ ```
75
+
76
+ **GPU环境**
77
+
78
+ ```bash
79
+ # 请首先激活CONDA
80
+ conda create -n onescience311 python=3.11 -y libstdcxx-ng=12 libgcc-ng=12 gcc_linux-64=12 gxx_linux-64=12
81
+ conda activate onescience311
82
+ # 支持uv安装
83
+ pip install onescience[earth-gpu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
84
+ ```
85
+
86
+ ### 训练数据介绍
87
+
88
+ 本仓库使用少量虚拟样本验证工程流程,训练数据和测试数据分别保存为 `data/train.npz` 和 `data/test.npz`。虚拟数据包含 Sentinel-2 10 波段、Landsat 6 波段和 Sentinel-1 2 波段影像,并为每个传感器提供官方元数据对应的中心波长和地面采样距离。样本同时包含 week、hour、latitude、longitude 元数据、合成 teacher 表征目标,以及用于轻量评估的分类和回归目标。
89
+
90
+ 默认输入尺寸为 64×64,训练集包含 4 个样本,测试集包含 2 个样本。该规模只用于验证动态波段接口、时空元数据编码、MAE 训练和多传感器推理流程,不代表官方约 7000 万全球遥感 chips 的数据分布与训练规模。
91
+
92
+ ```bash
93
+ python scripts/fake_data.py
94
+ ```
95
+
96
+ ### 训练
97
+
98
+ ```bash
99
+ python scripts/train.py
100
+ ```
101
+
102
+ 多卡训练可使用:
103
+
104
+ ```bash
105
+ torchrun --nproc_per_node=8 --nnodes=1 --rdzv_id=1000 --rdzv_backend=c10d --max_restarts=0 --master_addr="localhost" --master_port=29500 scripts/train.py
106
+ ```
107
+
108
+ 默认配置面向快速流程验证;开展正式实验时,应使用真实遥感数据、官方尺寸配置、DINOv2 teacher 和完整训练周期。
109
+
110
+ ```text
111
+ result/checkpoints/clayfoundation.pt
112
+ result/training/metrics.json
113
+ ```
114
+
115
+ ### 训练权重
116
+
117
+ 本仓库不在 `weight/` 中内置虚拟权重或官方权重。Clay Foundation 已公开 Clay v1.5 官方 checkpoint:
118
+
119
+ https://huggingface.co/made-with-clay/Clay/resolve/main/v1.5/clay-v1.5.ckpt
120
+
121
+ 本仓库是缩小的独立工程实现,模型参数名称和尺寸不与官方约 1.25 GB Encoder 权重兼容。需要使用官方权重时,应采用 Clay 官方仓库提供的模型实现和数据预处理流程。
122
+
123
+ ### 推理
124
+
125
+ ```bash
126
+ python scripts/inference.py
127
+ ```
128
+
129
+ 推理加载训练 checkpoint,分别处理 Sentinel-2、Landsat 和 Sentinel-1 测试影像,生成 Encoder embedding、L2 归一化投影 embedding、完整多波段 MAE 重建结果,以及重建损失和 teacher 表征对齐损失,并保存到:
130
+
131
+ ```text
132
+ result/output/predictions.npz
133
+ ```
134
+
135
+ ### 评估和可视化
136
+
137
+ ```bash
138
+ python scripts/result.py
139
+ ```
140
+
141
+ 评估模型在不同传感器上的影像重建与表征效果,并统计重建误差、embedding 范数和表征相似度。评估过程同时生成输入影像、重建结果和绝对误差对比图。虚拟数据结果仅用于验证工程流程,不代表官方 Clay v1.5 的训练损失、真实下游任务性能或跨区域泛化能力。
142
+
143
+ ```text
144
+ result/evaluation/metrics.json
145
+ result/evaluation/comparison.png
146
+ ```
147
+
148
+ # OneScience官方信息
149
+
150
+ | 平台 | OneScience 主仓库 | Skills 仓库 |
151
+ | --- | --- | --- |
152
+ | Gitee | https://gitee.com/onescience-ai/onescience | https://gitee.com/onescience-ai/oneskills |
153
+ | GitHub | https://github.com/onescience-ai/OneScience | https://github.com/onescience-ai/oneskills |
154
+
155
+ # 引用与许可证
156
+
157
+ 本仓库为 Clay Foundation Model v1.5 公开规格的独立工程复现版本。Clay 官方源码和模型权重采用 Apache-2.0 许可证。
158
+
159
+ Clay 官方仓库:
160
+ https://github.com/Clay-foundation/model
161
+
162
+ 本仓库代码、官方模型权重和数据的使用仍应以各自项目中的许可证及使用条款为准。
conf/config.yaml ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ seed: 42
2
+ data:
3
+ root: data
4
+ format_version: clay_synthetic_v1_5_engineering_v1
5
+ train_samples: 4
6
+ test_samples: 2
7
+ image_size: 64
8
+ sensors:
9
+ sentinel-2-l2a:
10
+ channels: 10
11
+ gsd: 10.0
12
+ wavelengths_nm: [493, 560, 665, 704, 740, 783, 842, 865, 1610, 2190]
13
+ rgb_indices: [2, 1, 0]
14
+ landsat-c2l2-sr:
15
+ channels: 6
16
+ gsd: 30.0
17
+ wavelengths_nm: [650, 560, 480, 860, 1600, 2200]
18
+ rgb_indices: [0, 1, 2]
19
+ sentinel-1-rtc:
20
+ channels: 2
21
+ gsd: 10.0
22
+ wavelengths_nm: [3500, 4000]
23
+ rgb_indices: [0, 1, 0]
24
+ model:
25
+ patch_size: 8
26
+ mask_ratio: 0.75
27
+ encoder_dim: 96
28
+ encoder_depth: 2
29
+ encoder_heads: 4
30
+ decoder_dim: 64
31
+ decoder_depth: 1
32
+ decoder_heads: 4
33
+ wave_dim: 32
34
+ wave_latents: 16
35
+ teacher_dim: 128
36
+ norm_pix_loss: false
37
+ paper_model:
38
+ patch_size: 8
39
+ mask_ratio: 0.75
40
+ encoder_dim: 1024
41
+ encoder_depth: 24
42
+ encoder_heads: 16
43
+ decoder_dim: 512
44
+ decoder_depth: 4
45
+ decoder_heads: 4
46
+ parameter_count: 632M_including_DINOv2_teacher
47
+ encoder_parameter_count: 311M
48
+ teacher: DINOv2_304M
49
+ train:
50
+ epochs: 1
51
+ batch_size: 2
52
+ learning_rate: 0.0005
53
+ weight_decay: 0.000001
54
+ reconstruction_weight: 0.95
55
+ representation_weight: 0.05
56
+ num_workers: 0
57
+ runtime:
58
+ device: auto
59
+ paths:
60
+ checkpoint: result/checkpoints/clayfoundation.pt
61
+ training_metrics: result/training/metrics.json
62
+ inference_dir: result/output
63
+ evaluation_dir: result/evaluation
config.json ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model_name": "ClayFoundation",
3
+ "model_type": "clayfoundation",
4
+ "architectures": [
5
+ "ClayFoundation",
6
+ "DynamicEmbedding",
7
+ "Transformer",
8
+ "DynamicDecoder"
9
+ ],
10
+ "framework": "PyTorch",
11
+ "domain": "earth-observation",
12
+ "task": "multi-sensor-remote-sensing-representation-learning",
13
+ "implementation": {
14
+ "entry_point": "model/clayfoundation.py",
15
+ "scope": "scaled-down independent engineering reproduction of the Clay v1.5 multi-sensor masked-autoencoder specification",
16
+ "train_script": "scripts/train.py",
17
+ "inference_script": "scripts/inference.py",
18
+ "evaluation_script": "scripts/result.py",
19
+ "synthetic_data_script": "scripts/fake_data.py"
20
+ },
21
+ "architecture": {
22
+ "family": "multi-sensor masked autoencoder with wavelength-conditioned dynamic embeddings",
23
+ "patch_size": 8,
24
+ "mask_ratio": 0.75,
25
+ "encoder_dim": 96,
26
+ "encoder_depth": 2,
27
+ "encoder_heads": 4,
28
+ "decoder_dim": 64,
29
+ "decoder_depth": 1,
30
+ "decoder_heads": 4,
31
+ "wave_dim": 32,
32
+ "wave_latents": 16,
33
+ "teacher_dim": 128,
34
+ "norm_pix_loss": false,
35
+ "metadata": [
36
+ "week",
37
+ "hour",
38
+ "latitude",
39
+ "longitude",
40
+ "ground_sample_distance",
41
+ "band_center_wavelengths"
42
+ ],
43
+ "paper_model": {
44
+ "patch_size": 8,
45
+ "mask_ratio": 0.75,
46
+ "encoder_dim": 1024,
47
+ "encoder_depth": 24,
48
+ "encoder_heads": 16,
49
+ "decoder_dim": 512,
50
+ "decoder_depth": 4,
51
+ "decoder_heads": 4,
52
+ "parameter_count": "632M including DINOv2 teacher",
53
+ "encoder_parameter_count": "311M",
54
+ "teacher": "DINOv2 304M"
55
+ }
56
+ },
57
+ "data": {
58
+ "datasets": [
59
+ "Synthetic Multi-Sensor Earth Observation Chips"
60
+ ],
61
+ "protocol": "clay_synthetic_v1_5_engineering_v1",
62
+ "format": "NPZ",
63
+ "train_file": "data/train.npz",
64
+ "test_file": "data/test.npz",
65
+ "image_size": 64,
66
+ "train_samples": 4,
67
+ "test_samples": 2,
68
+ "sensors": {
69
+ "sentinel-2-l2a": {
70
+ "channels": 10,
71
+ "gsd_m": 10.0,
72
+ "wavelengths_nm": [493, 560, 665, 704, 740, 783, 842, 865, 1610, 2190],
73
+ "rgb_indices": [2, 1, 0]
74
+ },
75
+ "landsat-c2l2-sr": {
76
+ "channels": 6,
77
+ "gsd_m": 30.0,
78
+ "wavelengths_nm": [650, 560, 480, 860, 1600, 2200],
79
+ "rgb_indices": [0, 1, 2]
80
+ },
81
+ "sentinel-1-rtc": {
82
+ "channels": 2,
83
+ "gsd_m": 10.0,
84
+ "wavelengths_nm": [3500, 4000],
85
+ "rgb_indices": [0, 1, 0]
86
+ }
87
+ },
88
+ "required_metadata": [
89
+ "format_version",
90
+ "data_source",
91
+ "time",
92
+ "latlon",
93
+ "class_target",
94
+ "regression_target",
95
+ "teacher_target",
96
+ "pixels",
97
+ "valid",
98
+ "wavelengths",
99
+ "gsd"
100
+ ]
101
+ },
102
+ "configuration_sources": [
103
+ "conf/config.yaml",
104
+ "model/clayfoundation.py",
105
+ "scripts/fake_data.py",
106
+ "scripts/train.py",
107
+ "scripts/inference.py",
108
+ "scripts/result.py"
109
+ ]
110
+ }
configuration.json ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "framework": "PyTorch",
3
+ "task": "earth_observation_representation_learning",
4
+ "model": "Clay Foundation Model",
5
+ "input_format": "BCHW",
6
+ "protocol": "engineering_mae_training_and_inference",
7
+ "default_config": "conf/config.yaml",
8
+ "training": "scripts/train.py",
9
+ "inference": "scripts/inference.py",
10
+ "evaluation": "scripts/result.py",
11
+ "visualization": "scripts/result.py"
12
+ }
model/clayfoundation.py ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pure-PyTorch engineering reproduction of the Clay v1.5 model specification."""
2
+
3
+ import math
4
+
5
+ import torch
6
+ from torch import nn
7
+ from torch.nn import functional as F
8
+
9
+
10
+ def fourier_encode(values, dim, max_frequency=10000.0):
11
+ """Encode scalar metadata while preserving exactly ``dim`` output features."""
12
+ if dim < 1:
13
+ return values.new_zeros(*values.shape, 0)
14
+ pairs = (dim + 1) // 2
15
+ frequencies = torch.exp(
16
+ torch.linspace(0, math.log(max_frequency), pairs, device=values.device, dtype=values.dtype)
17
+ )
18
+ angles = values.unsqueeze(-1) * frequencies
19
+ return torch.cat((angles.sin(), angles.cos()), dim=-1)[..., :dim]
20
+
21
+
22
+ def position_encoding_2d(height, width, dim, gsd, device, dtype):
23
+ if dim % 4:
24
+ raise ValueError("spatial position dimension must be divisible by four")
25
+ y, x = torch.meshgrid(
26
+ torch.arange(height, device=device, dtype=dtype),
27
+ torch.arange(width, device=device, dtype=dtype),
28
+ indexing="ij",
29
+ )
30
+ scale = torch.as_tensor(gsd, device=device, dtype=dtype) / 10.0
31
+ quarter = dim // 4
32
+ frequencies = torch.exp(
33
+ torch.arange(quarter, device=device, dtype=dtype) * (-math.log(10000.0) / max(quarter, 1))
34
+ )
35
+ x_angles = x.reshape(-1, 1) * scale * frequencies
36
+ y_angles = y.reshape(-1, 1) * scale * frequencies
37
+ return torch.cat((x_angles.sin(), x_angles.cos(), y_angles.sin(), y_angles.cos()), dim=-1)
38
+
39
+
40
+ def patchify(pixels, patch_size):
41
+ batch, channels, height, width = pixels.shape
42
+ if height % patch_size or width % patch_size:
43
+ raise ValueError("image dimensions must be divisible by patch_size")
44
+ return pixels.reshape(
45
+ batch, channels, height // patch_size, patch_size, width // patch_size, patch_size
46
+ ).permute(0, 2, 4, 1, 3, 5).reshape(batch, -1, channels * patch_size * patch_size)
47
+
48
+
49
+ def unpatchify(patches, channels, height, width, patch_size):
50
+ batch = patches.shape[0]
51
+ return patches.reshape(
52
+ batch, height // patch_size, width // patch_size, channels, patch_size, patch_size
53
+ ).permute(0, 3, 1, 4, 2, 5).reshape(batch, channels, height, width)
54
+
55
+
56
+ class Transformer(nn.Module):
57
+ def __init__(self, dim, depth, heads, mlp_ratio=4):
58
+ super().__init__()
59
+ layer = nn.TransformerEncoderLayer(
60
+ dim, heads, int(dim * mlp_ratio), activation="gelu", batch_first=True, norm_first=True
61
+ )
62
+ self.layers = nn.TransformerEncoder(layer, depth)
63
+ self.norm = nn.LayerNorm(dim)
64
+
65
+ def forward(self, values):
66
+ return self.norm(self.layers(values))
67
+
68
+
69
+ class DynamicEmbedding(nn.Module):
70
+ """Create sensor-agnostic patches by conditioning per-band kernels on wavelength."""
71
+
72
+ def __init__(self, patch_size, embed_dim, wave_dim, wave_latents):
73
+ super().__init__()
74
+ self.patch_size = patch_size
75
+ self.wave_dim = wave_dim
76
+ self.wave_mlp = nn.Sequential(nn.Linear(wave_dim, wave_dim), nn.GELU(), nn.Linear(wave_dim, wave_dim))
77
+ self.latents = nn.Parameter(torch.randn(wave_latents, wave_dim) * 0.02)
78
+ self.cross_attention = nn.MultiheadAttention(wave_dim, 4, batch_first=True)
79
+ self.kernel = nn.Linear(wave_dim, patch_size * patch_size * embed_dim)
80
+ self.bias = nn.Parameter(torch.zeros(embed_dim))
81
+ self.embed_dim = embed_dim
82
+
83
+ def forward(self, pixels, wavelengths):
84
+ batch, channels, height, width = pixels.shape
85
+ if wavelengths.ndim == 1:
86
+ wavelengths = wavelengths[None].expand(batch, -1)
87
+ if wavelengths.shape != (batch, channels):
88
+ raise ValueError(f"wavelengths must have shape {(batch, channels)}, got {tuple(wavelengths.shape)}")
89
+ wave_features = fourier_encode(wavelengths / 1000.0, self.wave_dim)
90
+ wave_features = self.wave_mlp(wave_features)
91
+ queries = self.latents[None].expand(batch, -1, -1)
92
+ context = self.cross_attention(queries, wave_features, wave_features, need_weights=False)[0].mean(dim=1)
93
+ conditioned = wave_features + context[:, None]
94
+ kernels = self.kernel(conditioned).reshape(
95
+ batch, channels, self.embed_dim, self.patch_size, self.patch_size
96
+ )
97
+ patches = []
98
+ for index in range(batch):
99
+ patches.append(F.conv2d(pixels[index:index + 1], kernels[index].permute(1, 0, 2, 3),
100
+ stride=self.patch_size) + self.bias[None, :, None, None])
101
+ return torch.cat(patches).flatten(2).transpose(1, 2), conditioned
102
+
103
+
104
+ class DynamicDecoder(nn.Module):
105
+ def __init__(self, patch_size, decoder_dim, wave_dim):
106
+ super().__init__()
107
+ self.patch_size = patch_size
108
+ self.wave_mlp = nn.Sequential(nn.Linear(wave_dim, decoder_dim), nn.GELU(), nn.Linear(decoder_dim, decoder_dim))
109
+ self.output = nn.Linear(decoder_dim, patch_size * patch_size)
110
+
111
+ def forward(self, tokens, wave_features):
112
+ wave_context = self.wave_mlp(wave_features)
113
+ joint = tokens[:, :, None, :] + wave_context[:, None, :, :]
114
+ return self.output(joint).permute(0, 1, 2, 3).flatten(2)
115
+
116
+
117
+ class ClayFoundation(nn.Module):
118
+ def __init__(self, config):
119
+ super().__init__()
120
+ self.config = dict(config)
121
+ self.patch_size = int(config["patch_size"])
122
+ self.mask_ratio = float(config["mask_ratio"])
123
+ enc_dim, dec_dim = int(config["encoder_dim"]), int(config["decoder_dim"])
124
+ if enc_dim < 12 or (enc_dim - 8) % 4:
125
+ raise ValueError("encoder_dim - 8 must be positive and divisible by four")
126
+ if dec_dim < 12 or (dec_dim - 8) % 4:
127
+ raise ValueError("decoder_dim - 8 must be positive and divisible by four")
128
+ self.dynamic_embedding = DynamicEmbedding(
129
+ self.patch_size, enc_dim, int(config["wave_dim"]), int(config["wave_latents"])
130
+ )
131
+ self.cls_token = nn.Parameter(torch.randn(1, 1, enc_dim) * 0.02)
132
+ self.encoder = Transformer(enc_dim, int(config["encoder_depth"]), int(config["encoder_heads"]))
133
+ self.encoder_to_decoder = nn.Linear(enc_dim, dec_dim)
134
+ self.mask_token = nn.Parameter(torch.randn(1, 1, dec_dim) * 0.02)
135
+ self.decoder = Transformer(dec_dim, int(config["decoder_depth"]), int(config["decoder_heads"]))
136
+ self.wave_to_decoder = nn.Linear(int(config["wave_dim"]), int(config["wave_dim"]))
137
+ self.dynamic_decoder = DynamicDecoder(self.patch_size, dec_dim, int(config["wave_dim"]))
138
+ self.representation_head = nn.Linear(enc_dim, int(config["teacher_dim"]))
139
+ self.norm_pix_loss = bool(config.get("norm_pix_loss", False))
140
+
141
+ @staticmethod
142
+ def _metadata_encoding(time, latlon, dim):
143
+ values = torch.cat((time, latlon), dim=1)
144
+ widths = [dim // 4] * 4
145
+ for index in range(dim % 4):
146
+ widths[index] += 1
147
+ return torch.cat([fourier_encode(values[:, index], widths[index]) for index in range(4)], dim=1)
148
+
149
+ def _add_encoding(self, tokens, time, latlon, gsd):
150
+ batch, length, dim = tokens.shape
151
+ grid = int(math.sqrt(length))
152
+ if grid * grid != length:
153
+ raise ValueError("Clay reproduction requires a square patch grid")
154
+ spatial = position_encoding_2d(grid, grid, dim - 8, gsd, tokens.device, tokens.dtype)
155
+ metadata = self._metadata_encoding(time, latlon, 8)
156
+ encoding = torch.cat((spatial[None].expand(batch, -1, -1), metadata[:, None].expand(-1, length, -1)), dim=-1)
157
+ return tokens + encoding
158
+
159
+ def encode(self, pixels, time, latlon, gsd, wavelengths):
160
+ patches, _ = self.dynamic_embedding(pixels, wavelengths)
161
+ patches = self._add_encoding(patches, time, latlon, gsd)
162
+ cls = self.cls_token.expand(len(pixels), -1, -1)
163
+ encoded = self.encoder(torch.cat((cls, patches), dim=1))
164
+ return encoded[:, 0], encoded[:, 1:]
165
+
166
+ def forward(self, pixels, time, latlon, gsd, wavelengths, teacher_target=None, mask_ratio=None):
167
+ ratio = self.mask_ratio if mask_ratio is None else float(mask_ratio)
168
+ patches, wave_features = self.dynamic_embedding(pixels, wavelengths)
169
+ patches = self._add_encoding(patches, time, latlon, gsd)
170
+ batch, length, _ = patches.shape
171
+ keep = max(1, length - int(length * ratio))
172
+ noise = torch.rand(batch, length, device=pixels.device)
173
+ ordering = noise.argsort(dim=1)
174
+ unmasked_indices, masked_indices = ordering[:, :keep], ordering[:, keep:]
175
+ gather = unmasked_indices[:, :, None].expand(-1, -1, patches.shape[-1])
176
+ visible = patches.gather(1, gather)
177
+ encoded = self.encoder(torch.cat((self.cls_token.expand(batch, -1, -1), visible), dim=1))
178
+ embedding = encoded[:, 0]
179
+
180
+ decoded_visible = self.encoder_to_decoder(encoded[:, 1:])
181
+ decoder_tokens = self.mask_token.expand(batch, length, -1).clone()
182
+ decoder_tokens.scatter_(1, unmasked_indices[:, :, None].expand(-1, -1, decoded_visible.shape[-1]), decoded_visible)
183
+ grid = int(math.sqrt(length))
184
+ spatial = position_encoding_2d(grid, grid, decoder_tokens.shape[-1] - 8, gsd,
185
+ decoder_tokens.device, decoder_tokens.dtype)
186
+ metadata = self._metadata_encoding(time, latlon, 8)
187
+ decoder_tokens = decoder_tokens + torch.cat((spatial[None].expand(batch, -1, -1),
188
+ metadata[:, None].expand(-1, length, -1)), dim=-1)
189
+ decoded = self.decoder(decoder_tokens)
190
+ predicted_patches = self.dynamic_decoder(decoded, self.wave_to_decoder(wave_features))
191
+ target_patches = patchify(pixels, self.patch_size)
192
+ if self.norm_pix_loss:
193
+ mean = target_patches.mean(dim=-1, keepdim=True)
194
+ variance = target_patches.var(dim=-1, keepdim=True)
195
+ target_patches = (target_patches - mean) / (variance + 1e-6).sqrt()
196
+ mask = torch.zeros(batch, length, device=pixels.device)
197
+ mask.scatter_(1, masked_indices, 1.0)
198
+ patch_loss = (predicted_patches - target_patches).abs().mean(dim=-1)
199
+ reconstruction_loss = (patch_loss * mask).sum() / mask.sum().clamp_min(1)
200
+ projected = F.normalize(self.representation_head(embedding), dim=1)
201
+ if teacher_target is None:
202
+ representation_loss = embedding.new_zeros(())
203
+ else:
204
+ representation_loss = 1.0 - (projected * F.normalize(teacher_target, dim=1)).sum(dim=1).mean()
205
+ reconstruction = unpatchify(predicted_patches, pixels.shape[1], pixels.shape[2], pixels.shape[3], self.patch_size)
206
+ return {
207
+ "embedding": embedding,
208
+ "projected_embedding": projected,
209
+ "reconstruction": reconstruction,
210
+ "mask": mask,
211
+ "reconstruction_loss": reconstruction_loss,
212
+ "representation_loss": representation_loss,
213
+ }
214
+
215
+
216
+ def compute_loss(outputs, reconstruction_weight=0.95, representation_weight=0.05):
217
+ total = reconstruction_weight * outputs["reconstruction_loss"] + representation_weight * outputs["representation_loss"]
218
+ return total, {
219
+ "reconstruction": outputs["reconstruction_loss"],
220
+ "representation": outputs["representation_loss"],
221
+ "total": total,
222
+ }
scripts/fake_data.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generate deterministic multi-sensor chips for the Clay engineering workflow."""
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+
6
+ import numpy as np
7
+ import yaml
8
+
9
+
10
+ ROOT = Path(__file__).resolve().parents[1]
11
+
12
+
13
+ def load_config():
14
+ return yaml.safe_load((ROOT / "conf/config.yaml").read_text())
15
+
16
+
17
+ def make_split(path, count, config, seed):
18
+ rng = np.random.default_rng(seed)
19
+ size = int(config["data"]["image_size"])
20
+ y, x = np.mgrid[-1:1:complex(size), -1:1:complex(size)].astype(np.float32)
21
+ payload = {
22
+ "format_version": np.asarray(config["data"]["format_version"]),
23
+ "data_source": np.asarray("synthetic"),
24
+ "time": np.empty((count, 2), np.float32),
25
+ "latlon": np.empty((count, 2), np.float32),
26
+ "class_target": np.empty(count, np.int64),
27
+ "regression_target": np.empty(count, np.float32),
28
+ "teacher_target": np.empty((count, config["model"]["teacher_dim"]), np.float32),
29
+ }
30
+ projection = rng.normal(size=(6, config["model"]["teacher_dim"])).astype(np.float32)
31
+ for sample in range(count):
32
+ phase = rng.uniform(0, 2 * np.pi)
33
+ week = rng.uniform(0, 2 * np.pi)
34
+ hour = rng.uniform(0, 2 * np.pi)
35
+ latitude = rng.uniform(-math_pi_over_two(), math_pi_over_two())
36
+ longitude = rng.uniform(-np.pi, np.pi)
37
+ payload["time"][sample] = (week, hour)
38
+ payload["latlon"][sample] = (latitude, longitude)
39
+ payload["class_target"][sample] = int(np.sin(phase) > 0)
40
+ payload["regression_target"][sample] = np.cos(phase) + 0.2 * np.sin(latitude)
41
+ descriptor = np.asarray([
42
+ np.sin(phase), np.cos(phase), np.sin(week), np.cos(week),
43
+ np.sin(latitude), np.cos(longitude),
44
+ ], np.float32)
45
+ target = descriptor @ projection
46
+ payload["teacher_target"][sample] = target / np.linalg.norm(target).clip(1e-6)
47
+
48
+ for sensor_index, (name, spec) in enumerate(config["data"]["sensors"].items()):
49
+ channels = int(spec["channels"])
50
+ pixels = np.empty((count, channels, size, size), np.float32)
51
+ valid = np.ones((count, 1, size, size), np.float32)
52
+ for sample in range(count):
53
+ phase = np.arctan2(payload["teacher_target"][sample, 0], payload["teacher_target"][sample, 1])
54
+ base = np.sin((2.0 + sensor_index) * np.pi * x + phase) * np.cos(2 * np.pi * y - phase)
55
+ base += 0.35 * x + 0.15 * y
56
+ for channel in range(channels):
57
+ spectral = 0.12 * channel + 0.2 * np.sin(phase + channel / max(channels, 1))
58
+ pixels[sample, channel] = base + spectral + rng.normal(0, 0.03, (size, size))
59
+ valid[sample, :, :2] = 0
60
+ pixels -= pixels.mean(axis=(0, 2, 3), keepdims=True)
61
+ pixels /= pixels.std(axis=(0, 2, 3), keepdims=True).clip(1e-6)
62
+ payload[f"pixels_{name}"] = np.clip(pixels, -6, 6).astype(np.float32)
63
+ payload[f"valid_{name}"] = valid
64
+ payload[f"wavelengths_{name}"] = np.tile(np.asarray(spec["wavelengths_nm"], np.float32), (count, 1))
65
+ payload[f"gsd_{name}"] = np.full(count, float(spec["gsd"]), np.float32)
66
+ np.savez_compressed(path, **payload)
67
+
68
+
69
+ def math_pi_over_two():
70
+ return np.pi / 2
71
+
72
+
73
+ def main():
74
+ parser = argparse.ArgumentParser()
75
+ parser.add_argument("--force", action="store_true")
76
+ args = parser.parse_args()
77
+ config = load_config()
78
+ data_dir = ROOT / config["data"]["root"]
79
+ data_dir.mkdir(parents=True, exist_ok=True)
80
+ for offset, (filename, count) in enumerate((
81
+ ("train.npz", config["data"]["train_samples"]),
82
+ ("test.npz", config["data"]["test_samples"]),
83
+ )):
84
+ path = data_dir / filename
85
+ if args.force or not path.exists():
86
+ make_split(path, int(count), config, int(config["seed"]) + offset)
87
+ print(f"generated={path.relative_to(ROOT)} samples={count} format={config['data']['format_version']}")
88
+
89
+
90
+ if __name__ == "__main__":
91
+ main()
scripts/inference.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generate Clay embeddings and MAE reconstructions for every configured sensor."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import numpy as np
7
+ import torch
8
+ import yaml
9
+
10
+
11
+ ROOT = Path(__file__).resolve().parents[1]
12
+ sys.path.insert(0, str(ROOT))
13
+ from model.clayfoundation import ClayFoundation
14
+ from train import ClayDataset, device_from_config
15
+
16
+
17
+ def main():
18
+ config = yaml.safe_load((ROOT / "conf/config.yaml").read_text())
19
+ device = device_from_config(config)
20
+ checkpoint = torch.load(ROOT / config["paths"]["checkpoint"], map_location=device, weights_only=True)
21
+ if checkpoint["format_version"] != config["data"]["format_version"]:
22
+ raise ValueError("checkpoint and data formats are incompatible")
23
+ model = ClayFoundation(checkpoint["model_config"]).to(device)
24
+ model.load_state_dict(checkpoint["model"])
25
+ model.eval()
26
+ dataset = ClayDataset(ROOT / config["data"]["root"] / "test.npz", config)
27
+ raw = dataset.data
28
+ payload = {
29
+ "format_version": np.asarray(config["data"]["format_version"]),
30
+ "class_target": raw["class_target"],
31
+ "regression_target": raw["regression_target"],
32
+ }
33
+ time = torch.from_numpy(raw["time"]).to(device)
34
+ latlon = torch.from_numpy(raw["latlon"]).to(device)
35
+ teacher = torch.from_numpy(raw["teacher_target"]).to(device)
36
+ with torch.no_grad():
37
+ for name, spec in config["data"]["sensors"].items():
38
+ pixels = torch.from_numpy(raw[f"pixels_{name}"]).to(device)
39
+ waves = torch.from_numpy(raw[f"wavelengths_{name}"]).to(device)
40
+ outputs = model(pixels, time, latlon, float(spec["gsd"]), waves, teacher, mask_ratio=0.0)
41
+ payload[f"embedding_{name}"] = outputs["embedding"].cpu().numpy()
42
+ payload[f"projected_embedding_{name}"] = outputs["projected_embedding"].cpu().numpy()
43
+ payload[f"reconstruction_{name}"] = outputs["reconstruction"].cpu().numpy()
44
+ payload[f"pixels_{name}"] = raw[f"pixels_{name}"]
45
+ payload[f"reconstruction_loss_{name}"] = np.asarray(float(outputs["reconstruction_loss"]))
46
+ payload[f"representation_loss_{name}"] = np.asarray(float(outputs["representation_loss"]))
47
+ output = ROOT / config["paths"]["inference_dir"] / "predictions.npz"
48
+ output.parent.mkdir(parents=True, exist_ok=True)
49
+ np.savez_compressed(output, **payload)
50
+ print(f"predictions={output.relative_to(ROOT)}")
51
+
52
+
53
+ if __name__ == "__main__":
54
+ main()
scripts/result.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Evaluate reconstruction and cross-sensor representation consistency."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import matplotlib
7
+ matplotlib.use("Agg")
8
+ import matplotlib.pyplot as plt
9
+ import numpy as np
10
+ import yaml
11
+
12
+
13
+ ROOT = Path(__file__).resolve().parents[1]
14
+
15
+
16
+ def main():
17
+ config = yaml.safe_load((ROOT / "conf/config.yaml").read_text())
18
+ predictions = np.load(ROOT / config["paths"]["inference_dir"] / "predictions.npz")
19
+ sensors = list(config["data"]["sensors"])
20
+ metrics = {"samples": int(len(predictions["class_target"])), "sensors": {}}
21
+ projected = []
22
+ for name in sensors:
23
+ embedding = predictions[f"embedding_{name}"]
24
+ projection = predictions[f"projected_embedding_{name}"]
25
+ projected.append(projection)
26
+ metrics["sensors"][name] = {
27
+ "reconstruction_mae": float(np.abs(predictions[f"reconstruction_{name}"] - predictions[f"pixels_{name}"]).mean()),
28
+ "embedding_norm": float(np.linalg.norm(embedding, axis=1).mean()),
29
+ "projected_embedding_norm": float(np.linalg.norm(projection, axis=1).mean()),
30
+ "representation_loss": float(predictions[f"representation_loss_{name}"]),
31
+ }
32
+ similarities = []
33
+ for left in range(len(projected)):
34
+ for right in range(left + 1, len(projected)):
35
+ similarities.append((projected[left] * projected[right]).sum(axis=1))
36
+ metrics["mean_cross_sensor_cosine_similarity"] = float(np.concatenate(similarities).mean())
37
+ output_dir = ROOT / config["paths"]["evaluation_dir"]
38
+ output_dir.mkdir(parents=True, exist_ok=True)
39
+ (output_dir / "metrics.json").write_text(json.dumps(metrics, indent=2) + "\n")
40
+
41
+ figure, axes = plt.subplots(len(sensors), 3, figsize=(9, 3 * len(sensors)), squeeze=False)
42
+ for row, name in enumerate(sensors):
43
+ rgb = config["data"]["sensors"][name]["rgb_indices"]
44
+ source = predictions[f"pixels_{name}"][0, rgb].transpose(1, 2, 0)
45
+ reconstruction = predictions[f"reconstruction_{name}"][0, rgb].transpose(1, 2, 0)
46
+ for image in (source, reconstruction):
47
+ image -= image.min()
48
+ image /= image.max() + 1e-6
49
+ axes[row, 0].imshow(source)
50
+ axes[row, 0].set_title(f"{name} input")
51
+ axes[row, 1].imshow(reconstruction)
52
+ axes[row, 1].set_title("MAE reconstruction")
53
+ axes[row, 2].imshow(np.abs(source - reconstruction).mean(axis=2), cmap="magma")
54
+ axes[row, 2].set_title("absolute error")
55
+ for axis in axes[row]:
56
+ axis.axis("off")
57
+ figure.tight_layout()
58
+ figure.savefig(output_dir / "comparison.png", dpi=150)
59
+ plt.close(figure)
60
+ print(f"metrics={output_dir.relative_to(ROOT) / 'metrics.json'}")
61
+
62
+
63
+ if __name__ == "__main__":
64
+ main()
scripts/train.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Train the reduced Clay MAE on deterministic multi-sensor synthetic chips."""
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+ import torch
10
+ import yaml
11
+ from torch.nn.parallel import DistributedDataParallel
12
+ from torch.utils.data import DataLoader, Dataset, DistributedSampler
13
+
14
+
15
+ ROOT = Path(__file__).resolve().parents[1]
16
+ sys.path.insert(0, str(ROOT))
17
+ from model.clayfoundation import ClayFoundation, compute_loss
18
+
19
+
20
+ class ClayDataset(Dataset):
21
+ def __init__(self, path, config):
22
+ self.data = np.load(path)
23
+ self.sensors = config["data"]["sensors"]
24
+ if str(self.data["format_version"]) != config["data"]["format_version"]:
25
+ raise ValueError("incompatible synthetic data format")
26
+ size = int(config["data"]["image_size"])
27
+ for name, spec in self.sensors.items():
28
+ expected = (int(spec["channels"]), size, size)
29
+ if self.data[f"pixels_{name}"].shape[1:] != expected:
30
+ raise ValueError(f"{name} shape does not match {expected}")
31
+
32
+ def __len__(self):
33
+ return len(self.data["time"])
34
+
35
+ def __getitem__(self, index):
36
+ item = {
37
+ "time": torch.from_numpy(self.data["time"][index]),
38
+ "latlon": torch.from_numpy(self.data["latlon"][index]),
39
+ "teacher_target": torch.from_numpy(self.data["teacher_target"][index]),
40
+ }
41
+ for name in self.sensors:
42
+ item[f"pixels_{name}"] = torch.from_numpy(self.data[f"pixels_{name}"][index])
43
+ item[f"valid_{name}"] = torch.from_numpy(self.data[f"valid_{name}"][index])
44
+ item[f"waves_{name}"] = torch.from_numpy(self.data[f"wavelengths_{name}"][index])
45
+ return item
46
+
47
+
48
+ def device_from_config(config, local_rank=0):
49
+ requested = config["runtime"]["device"]
50
+ if requested == "auto":
51
+ return torch.device("cuda", local_rank) if torch.cuda.is_available() else torch.device("cpu")
52
+ return torch.device(requested)
53
+
54
+
55
+ def main():
56
+ config = yaml.safe_load((ROOT / "conf/config.yaml").read_text())
57
+ torch.manual_seed(int(config["seed"]))
58
+ distributed = int(os.environ.get("WORLD_SIZE", "1")) > 1
59
+ local_rank = int(os.environ.get("LOCAL_RANK", "0"))
60
+ if distributed:
61
+ torch.distributed.init_process_group("nccl" if torch.cuda.is_available() else "gloo")
62
+ rank = torch.distributed.get_rank() if distributed else 0
63
+ device = device_from_config(config, local_rank)
64
+ if device.type == "cuda":
65
+ torch.cuda.set_device(device)
66
+ dataset = ClayDataset(ROOT / config["data"]["root"] / "train.npz", config)
67
+ sampler = DistributedSampler(dataset, shuffle=True) if distributed else None
68
+ loader = DataLoader(dataset, batch_size=int(config["train"]["batch_size"]), sampler=sampler,
69
+ shuffle=sampler is None, num_workers=int(config["train"]["num_workers"]))
70
+ model = ClayFoundation(config["model"]).to(device)
71
+ if distributed:
72
+ model = DistributedDataParallel(model, device_ids=[local_rank] if device.type == "cuda" else None)
73
+ optimizer = torch.optim.AdamW(model.parameters(), lr=float(config["train"]["learning_rate"]),
74
+ weight_decay=float(config["train"]["weight_decay"]), betas=(0.9, 0.95))
75
+ history = []
76
+ for epoch in range(int(config["train"]["epochs"])):
77
+ if sampler:
78
+ sampler.set_epoch(epoch)
79
+ model.train()
80
+ totals = {"reconstruction": 0.0, "representation": 0.0, "total": 0.0}
81
+ steps = 0
82
+ for batch in loader:
83
+ optimizer.zero_grad(set_to_none=True)
84
+ sensor_losses = []
85
+ components = []
86
+ for name, spec in config["data"]["sensors"].items():
87
+ pixels = batch[f"pixels_{name}"].to(device) * batch[f"valid_{name}"].to(device)
88
+ outputs = model(pixels, batch["time"].to(device), batch["latlon"].to(device),
89
+ float(spec["gsd"]), batch[f"waves_{name}"].to(device),
90
+ batch["teacher_target"].to(device))
91
+ loss, values = compute_loss(outputs, float(config["train"]["reconstruction_weight"]),
92
+ float(config["train"]["representation_weight"]))
93
+ sensor_losses.append(loss)
94
+ components.append(values)
95
+ loss = torch.stack(sensor_losses).mean()
96
+ loss.backward()
97
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
98
+ optimizer.step()
99
+ for key in totals:
100
+ totals[key] += sum(float(item[key].detach()) for item in components) / len(components)
101
+ steps += 1
102
+ metrics = {key: value / max(steps, 1) for key, value in totals.items()}
103
+ history.append({"epoch": epoch + 1, **metrics})
104
+ if rank == 0:
105
+ print(f"epoch={epoch + 1} total_loss={metrics['total']:.6f} reconstruction={metrics['reconstruction']:.6f}")
106
+ if rank == 0:
107
+ checkpoint = ROOT / config["paths"]["checkpoint"]
108
+ metrics_path = ROOT / config["paths"]["training_metrics"]
109
+ checkpoint.parent.mkdir(parents=True, exist_ok=True)
110
+ metrics_path.parent.mkdir(parents=True, exist_ok=True)
111
+ state = model.module.state_dict() if distributed else model.state_dict()
112
+ torch.save({"model": state, "model_config": config["model"], "sensors": config["data"]["sensors"],
113
+ "format_version": config["data"]["format_version"]}, checkpoint)
114
+ metrics_path.write_text(json.dumps({"history": history}, indent=2) + "\n")
115
+ print(f"checkpoint={checkpoint.relative_to(ROOT)}")
116
+ if distributed:
117
+ torch.distributed.destroy_process_group()
118
+
119
+
120
+ if __name__ == "__main__":
121
+ main()
weight/.gitkeep ADDED
File without changes