Code for my ETH MSc semester project on visual counting. You can access the original paper under the following link: https://arxiv.org/abs/2605.30170. The repository contains:
- a synthetic Go-board counting dataset generator,
- a small custom vision-language model for text-only pretraining and multimodal finetuning,
- evaluation scripts for the custom model and Qwen3VL,
- probing scripts that test whether hidden representations encode grid-level labels and black-stone counts,
- intervention and pruning-analysis utilities.
The core task is to count black stones on a board, or to compare that count against a text sequence with the same controlled counting structure.
| Path | Purpose |
|---|---|
data.py |
Generates JSONL data for synthetic 19x19 custom-model experiments or 6x6 Qwen3VL experiments. |
generate_training_data_json.py |
Converts generated formal data into the chat-style JSONL format used by model.py. |
model.py |
Custom VLM implementation, processor, trainer, and training entry point. |
tokenizer.py |
Builds/loads the custom WordLevel tokenizer used by the custom VLM. |
mrope.py |
Multimodal RoPE utilities used by the custom VLM. |
test_model.py |
Runs generation evaluation for the custom VLM. |
test_acc.py |
Computes accuracy summaries from test_model.py JSONL logs. |
test_qwen3vl.py |
Runs Qwen3VL text-only or multimodal counting evaluation. |
train_hidden_num_qwen3vl.py |
Trains one Qwen3VL vision-feature probe using get_image_features. |
infer_hidden_num_qwen3vl_probe.py |
Runs inference with the saved Qwen3VL vision-feature probe. |
train_lm_layer_hidden_num_qwen3vl.py |
Trains one hidden-number probe per Qwen3VL LM hidden-state layer. |
infer_lm_layer_hidden_num_qwen3vl.py |
Evaluates all saved Qwen3VL LM-layer probes. |
hidden_number_probing_clf.py |
Trains a patch-level classifier on the custom VLM vision encoder. |
test_hidden_num.py |
Evaluates the custom VLM patch-level classifier by counting predicted black patches. |
causal_hidden_number_intervention.py |
Masks probe-positive image tokens and checks whether predicted counts drop by the masked amount. |
test_head_deactivation_text.py / test_head_deactivation_vision.py |
Tests custom-model attention-head deactivation effects. |
analyse_pruning_intensity.py |
Computes position-wise intensity statistics for 2x2 pruning vectors. |
compare_pruning_vectors.py |
Compares corresponding keys in two pruning-vector JSONL files. |
The scripts assume Python 3.11.6 with CUDA for model training and Qwen3VL inference:
pip install torch torchvision transformers tokenizers pillow tqdm numpy scikit-learn joblib qwen-vl-utilspython data.py \
--training_data_point 8192 \
--model_type syntheticThis writes:
training_data_formal.jsonltesting_data_formal.jsonltesting_data_formal_ood.jsonl
For model_type=synthetic, boards are 19x19 and counts range from 0 to 120. The default domain split used elsewhere is:
- in-domain: 0-49 black stones,
- visual extrapolation: 50-99 black stones,
- full extrapolation: 100-120 black stones.
python data.py \
--training_data_point 8192 \
--model_type qwen3vlThis writes:
testing_data_qwen3vl_6x6_0_to_20.jsonl
For Qwen3VL, boards are 6x6 and counts range from 0 to 20. Only testing data is generated and stored. Records include both image sizes:
img_c: smaller rendered board with grid size 16 px * 16 px,img_c_2: larger rendered board aligned with Qwen3VL's default visual patch/merge setup: grid size 32 px * 32 px.
Text-only pretraining data:
python generate_training_data_json.py \
--process_type pretrain \
--output_type num \
--data_path ./training_data_formal.jsonlMultimodal finetuning data:
python generate_training_data_json.py \
--process_type finetune \
--output_type num \
--data_path ./training_data_formal.jsonlUse --output_type tf for true/false comparison experiments. Output filenames encode the process, output type, vision boundary, and text boundary, for example:
training_19x19_text_pretrain_output_num_vision_49_text_99.jsonltraining_19x19_multimodal_finetune_output_num_vision_49_text_99.jsonl
python model.py \
--data_path ./training_19x19_text_pretrain_output_num_vision_49_text_99.jsonl \
--mode train \
--output_type numThe tokenizer is created automatically at ./tokenizer_formal.json if it does not already exist.
python model.py \
--data_path ./training_19x19_multimodal_finetune_output_num_vision_49_text_99.jsonl \
--model_path ./custom-model-pretrained-num-vision-50-text-100 \
--mode finetune \
--output_type numFor finetuning, --model_path is required and must match the requested --output_type.
python test_model.py \
--mode multimodal \
--output_type num \
--model_type finetune \
--model_path ./custom-model-finetune-num-vision-50-text-100Text-only evaluation:
python test_model.py \
--mode text \
--output_type num \
--model_type finetune \
--model_path ./custom-model-finetune-num-vision-50-text-100Accuracy helper:
python test_acc.py \
--log_file ./answer-output-finetune-multimodal-num-vision-50-text-100.log \
--output_type numtest_acc.py currently builds the summary but does not print it because the relevant print loops are commented out near the end of the file. Uncomment the desired block to print per-domain accuracy, per-count accuracy, average error, or generated-number frequencies.
python test_qwen3vl.py \
--output_type num \
--input_type multimodal \
--model_path Qwen/Qwen3-VL-32B-InstructText-only Qwen3VL evaluation:
python test_qwen3vl.py \
--output_type num \
--input_type text_only \
--model_path Qwen/Qwen3-VL-32B-InstructThe Qwen3VL prompt asks the model to emit a dense verification trace followed by FINAL_ANSWER: <Result>. If no final answer is found, test_qwen3vl.py appends FINAL_ANSWER: -1. to the log.
The Qwen3VL probe scripts intentionally split the 6x6 JSONL by line index:
- training probes use lines where
idx >= 100 and idx % 10 == 9, - inference probes use the remaining lines.
This keeps a balanced held-out subset for testing.
Train a probe on Qwen3VL image features:
python train_hidden_num_qwen3vl.py --model_path Qwen/Qwen3-VL-32B-Instruct This saves:
./qwen3vl/probe_output/vision_get_image_features_probe.joblib
Run inference:
python infer_hidden_num_qwen3vl_probe.py \
--model_path Qwen/Qwen3-VL-32B-Instruct \
--probe_path ./qwen3vl/probe_output/vision_get_image_features_probe.joblib \
--out_file ./qwen3vl/probe_output/rest_probe_predictions.jsonlLM Hidden-State Layer Probes
Train one probe per Qwen3VL language-model hidden-state layer:
python train_lm_layer_hidden_num_qwen3vl.py \
--model_path Qwen/Qwen3-VL-32B-Instruct \
--out_dir ./qwen3vl/lm_layer_probe_outputEach saved file is named:
lm_hidden_state_XX_probe.joblib
Run per-layer inference:
python infer_lm_layer_hidden_num_qwen3vl.py \
--model_path Qwen/Qwen3-VL-32B-Instruct \
--probe_dir ./qwen3vl/lm_layer_probe_output \
--out_file ./qwen3vl/lm_layer_probe_output/rest_lm_layer_probe_predictions.jsonlThe output JSONL contains the ground-truth num_black and each layer probe's predicted black-stone count.
python hidden_number_probing_clf.py \
--model_path ./custom-model-finetune-num-vision-50-text-100 \
--output_dir ./prediction_headThis saves:
./prediction_head/prediction_head_vit.joblib
python test_hidden_num.py \
--model_path ./custom-model-finetune-num-vision-50-text-100 \
--head_path ./prediction_head/prediction_head_vit.joblibCausal Hidden-Number Intervention
python causal_hidden_number_intervention.py \
--model_path ./custom-model-finetune-num-vision-50-text-100 \
--head_path ./prediction_head/prediction_head_vit.joblib \
--data_path ./testing_data_formal.jsonl \
--output_path ./causal_hidden_number_accuracy.json \
--max_mask_count 5For each valid in-domain example, this script:
- extracts vision-token embeddings,
- predicts black-stone token indices using the patch classifier,
- masks
kpredicted black-stone tokens, - checks whether the generated count becomes
ground_truth - k.
Test the effect of deactivating individual custom-model attention heads for text inputs:
python test_head_deactivation_text.py \
--model_path ./custom-model-finetune-num-vision-50-text-100 \
--stride 8 \
--batch_size 1Test the effect of deactivating individual custom-model attention heads for vision inputs:
python test_head_deactivation_vision.py \
--model_path ./custom-model-finetune-num-vision-50-text-100 \
--stride 8 \
--batch_size 1Both scripts iterate over decoder layers and heads, disable one head at a time during generation, and report the resulting counting accuracy. Use --stride to subsample the test set for faster sweeps.
Analyze position-wise intensity in nested 2x2 pruning vectors:
python analyse_pruning_intensity.py \
--path pruning_vectors_text.jsonl \
--key keep
python analyse_pruning_intensity.py \
--path pruning_vectors_vision.jsonl \
--key keepCompare corresponding JSONL records:
python compare_pruning_vectors.py \
--vision pruning_vectors_vision.jsonl \
--text pruning_vectors_text.jsonl \
--key keep