SteveZeyuZhang commited on
Commit
1cc4fea
·
1 Parent(s): 4df3053

Start resumable backup upload: root source files

Browse files
Files changed (9) hide show
  1. .gitattributes +6 -0
  2. CXR_CT_inference.py +117 -0
  3. README.md +1 -0
  4. check.py +10 -0
  5. data_process.py +143 -0
  6. retrieval_metric.py +463 -0
  7. run_CXR_CT_train.py +231 -0
  8. stage1_eva_i2t.py +393 -0
  9. stage1_eva_t2t.py +238 -0
.gitattributes CHANGED
@@ -33,3 +33,9 @@ saved_model/**/* 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
 
 
 
 
 
 
 
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
36
+ hf_models/** filter=lfs diff=lfs merge=lfs -text
37
+ outputs/** filter=lfs diff=lfs merge=lfs -text
38
+ runs/** filter=lfs diff=lfs merge=lfs -text
39
+ wandb/** filter=lfs diff=lfs merge=lfs -text
40
+ __pycache__/** filter=lfs diff=lfs merge=lfs -text
41
+ dataset/** filter=lfs diff=lfs merge=lfs -text
CXR_CT_inference.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from transformer_maskgit import CTViT
2
+ from transformers import BertTokenizer, BertModel
3
+ from radir import RADIR
4
+ from data_process import load_2d_image_to_tensor, load_3d_image_to_tensor
5
+ import torch
6
+
7
+ MODALITY_DICT = {'CT': 0, 'CXR': 1}
8
+
9
+ if __name__ == '__main__':
10
+ # Determine best available device
11
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
12
+
13
+ # Load tokenizer and text encoder
14
+ tokenizer = BertTokenizer.from_pretrained('microsoft/BiomedVLP-CXR-BERT-specialized', do_lower_case=True)
15
+ text_encoder = BertModel.from_pretrained("microsoft/BiomedVLP-CXR-BERT-specialized").to(device)
16
+
17
+ # Initialize image encoder
18
+ image_encoder = CTViT(
19
+ dim=512,
20
+ codebook_size=8192,
21
+ image_size=480,
22
+ patch_size=20,
23
+ temporal_patch_size=10,
24
+ spatial_depth=8,
25
+ temporal_depth=6,
26
+ cls_depth=4,
27
+ dim_head=32,
28
+ heads=8
29
+ ).to(device)
30
+
31
+ # Initialize RadIR model
32
+ rad_ir = RADIR(
33
+ image_encoder=image_encoder,
34
+ text_encoder=text_encoder,
35
+ tokenizer=tokenizer,
36
+ dim_text=768,
37
+ dim_image=512,
38
+ dim_latent=512,
39
+ extra_latent_projection=False,
40
+ use_mlm=False,
41
+ downsample_image_embeds=False,
42
+ use_all_token_embeds=False
43
+ ).to(device)
44
+
45
+ # Load checkpoint
46
+ checkpoint_path = 'checkpoints/RadIR.pt'
47
+ rad_ir.load(checkpoint_path)
48
+ rad_ir.eval() # Set to evaluation mode
49
+
50
+ # Test examples
51
+ cxr_images = ['example/1.jpg', 'example/2.jpg']
52
+ cxr_reports = [
53
+ "Lateral view somewhat limited due to overlying motion artifact. The lungs are low in volume. There is no focal airspace consolidation to suggest pneumonia. A 1.2-cm calcified granuloma just below the medial aspect of the right hemidiaphragm is unchanged from prior study. No pleural effusions or pulmonary edema. There is no pneumothorax. The inferior sternotomy wire is fractured but unchanged. Surgical clips and vascular markers in the thorax are related to prior CABG surgery. No evidence of acute cardiopulmonary process.",
54
+ "Left PICC tip is seen terminating in the region of the distal left brachiocephalic vein. Tracheostomy tube is in unchanged standard position. The heart is moderately enlarged. Marked calcification of the aortic knob is again present. Mild pulmonary vascular congestion is similar. Bibasilar streaky airspace opacities are minimally improved. Previously noted left pleural effusion appears to have resolved. No pneumothorax is identified. Percutaneous gastrostomy tube is seen in the left upper quadrant. 1. Left PICC tip appears to terminate in the distal left brachiocephalic vein. 2. Mild pulmonary vascular congestion. 3. Interval improvement in aeration of the lung bases with residual streaky opacity likely reflective of atelectasis. Interval resolution of the left pleural effusion."
55
+ ]
56
+ anatomy_condition = ['right lung']
57
+
58
+ # Extract unconditional image-text embeddings
59
+ with torch.no_grad():
60
+ # Process images
61
+ image_tensors = [load_2d_image_to_tensor(image_path) for image_path in cxr_images]
62
+ batched_images = torch.stack(image_tensors, dim=0).to(device) # Shape: [B, C, D, H, W] -> [2, 1, 1, 480, 480]
63
+
64
+ # Process text
65
+ text_tokens = tokenizer(
66
+ cxr_reports,
67
+ return_tensors="pt",
68
+ padding="max_length",
69
+ truncation=True,
70
+ max_length=512
71
+ ).to(device)
72
+ print(text_tokens)
73
+
74
+ # Set modality indicator for CXR
75
+ modal_indices = torch.tensor([MODALITY_DICT['CXR']] * len(cxr_images)).to(device)
76
+
77
+ # Forward pass to get embeddings
78
+ image_embeddings, text_embeddings, _, _ = rad_ir(
79
+ text_tokens,
80
+ image=batched_images,
81
+ device=device,
82
+ is_condition=False, # Indicate unconditional scenario
83
+ return_latents=True,
84
+ modal_indexs=modal_indices,
85
+ modal_embedding=True
86
+ )
87
+
88
+ print(f"Image embedding shape: {image_embeddings.shape}") # [B, 512]
89
+ print(f"Text embedding shape: {text_embeddings.shape}") # [B, 512]
90
+
91
+ # Extract condition-specific features (focused on right lung)
92
+ con_batch_image = batched_images.unsqueeze(0) # Shape: [B, local_B, C, D, H, W] -> [1, 2, 1, 1, 480, 480]
93
+
94
+ # Set modality indicator for conditions
95
+ con_modal_indices = torch.tensor([MODALITY_DICT['CXR']] * len(con_batch_image)).to(device)
96
+
97
+ # Tokenize anatomical condition
98
+ anatomy_condition_tokens = tokenizer(
99
+ anatomy_condition,
100
+ return_tensors="pt",
101
+ padding="max_length",
102
+ truncation=True,
103
+ max_length=512
104
+ ).to(device)
105
+
106
+ # Extract conditional features
107
+ _, _, condition_feature, _ = rad_ir(
108
+ anatomy_condition_tokens,
109
+ image=con_batch_image,
110
+ device=device,
111
+ is_condition=True, # Indicate conditional scenario
112
+ return_latents=True,
113
+ modal_indexs=con_modal_indices,
114
+ modal_embedding=True
115
+ )
116
+
117
+ print(f"Condition feature shape: {condition_feature.shape}") # [1, 2, 512]
README.md ADDED
@@ -0,0 +1 @@
 
 
1
+ # HMCLIP
check.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+
3
+ # 加载文件
4
+ data = np.load('dataset/train_ratescore.npy')
5
+
6
+ # 查看维度(例如返回 (100, 224, 224, 3))
7
+ print(f"数据的形状: {data.shape}")
8
+
9
+ # 查看维度的数量(例如返回 4)
10
+ print(f"维度的数量: {data.ndim}")
data_process.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import nibabel as nib
2
+ import torch
3
+ import numpy as np
4
+ import torch.nn.functional as F
5
+ from PIL import Image
6
+ import random
7
+ def resize_array(array, current_spacing, target_spacing):
8
+ """
9
+ Resize the array to match the target spacing.
10
+
11
+ Args:
12
+ array (torch.Tensor): Input array to be resized.
13
+ current_spacing (tuple): Current voxel spacing (z_spacing, xy_spacing, xy_spacing).
14
+ target_spacing (tuple): Target voxel spacing (target_z_spacing, target_x_spacing, target_y_spacing).
15
+
16
+ Returns:
17
+ np.ndarray: Resized array.
18
+ """
19
+ # Calculate new dimensions
20
+ original_shape = array.shape[2:]
21
+ scaling_factors = [
22
+ current_spacing[i] / target_spacing[i] for i in range(len(original_shape))
23
+ ]
24
+ new_shape = [
25
+ int(original_shape[i] * scaling_factors[i]) for i in range(len(original_shape))
26
+ ]
27
+ # Resize the array
28
+ resized_array = F.interpolate(array, size=new_shape, mode='trilinear', align_corners=False).cpu().numpy()
29
+ return resized_array
30
+
31
+ def load_3d_image_to_tensor(path):
32
+
33
+ nii_img = nib.load(str(path))
34
+ img_data = nii_img.get_fdata()
35
+ img_data = np.flip(img_data, axis=0)
36
+ img_data = np.flip(img_data, axis=1)
37
+
38
+ # WARNING Respacing
39
+ img_data = img_data.transpose(2, 0, 1)
40
+ img_data = np.copy(img_data)
41
+ tensor = torch.tensor(img_data)
42
+ tensor = tensor.unsqueeze(0).unsqueeze(0)
43
+
44
+ target_x_spacing = 0.75
45
+ target_y_spacing = 0.75
46
+ target_z_spacing = 1.5
47
+ current = (3, 1, 1) # this is all set to 3 1 1
48
+ target = (target_z_spacing, target_x_spacing, target_y_spacing)
49
+
50
+ img_data = resize_array(tensor, current, target)
51
+ img_data = img_data[0][0]
52
+ img_data= np.transpose(img_data, (1, 2, 0))
53
+
54
+ # WARNING Normalization 2
55
+ hu_min, hu_max = -1000, 1000
56
+ img_data = np.clip(img_data, hu_min, hu_max)
57
+ img_data = (((img_data ) / 1000)).astype(np.float32)
58
+
59
+ tensor = torch.tensor(img_data)
60
+
61
+ # WARNING Padding or Crop
62
+
63
+ # Get the dimensions of the input tensor
64
+ target_shape = (480,480,240) # h w d
65
+
66
+ # Extract dimensions
67
+ h, w, d = tensor.shape
68
+
69
+ # Calculate cropping/padding values for height, width, and depth
70
+ dh, dw, dd = target_shape
71
+ h_start = max((h - dh) // 2, 0)
72
+ h_end = min(h_start + dh, h)
73
+ w_start = max((w - dw) // 2, 0)
74
+ w_end = min(w_start + dw, w)
75
+ d_start = max((d - dd) // 2, 0)
76
+ d_end = min(d_start + dd, d)
77
+
78
+ # Crop or pad the tensor
79
+ tensor = tensor[h_start:h_end, w_start:w_end, d_start:d_end]
80
+
81
+ pad_h_before = (dh - tensor.size(0)) // 2
82
+ pad_h_after = dh - tensor.size(0) - pad_h_before
83
+
84
+ pad_w_before = (dw - tensor.size(1)) // 2
85
+ pad_w_after = dw - tensor.size(1) - pad_w_before
86
+
87
+ pad_d_before = (dd - tensor.size(2)) // 2
88
+ pad_d_after = dd - tensor.size(2) - pad_d_before
89
+
90
+ tensor = torch.nn.functional.pad(tensor, (pad_d_before, pad_d_after, pad_w_before, pad_w_after, pad_h_before, pad_h_after), value=-1)
91
+
92
+ tensor = tensor.permute(2, 0, 1) # d h w
93
+
94
+ tensor = tensor.unsqueeze(0) # 1 d h w
95
+
96
+ return tensor
97
+
98
+
99
+ def load_2d_image_to_tensor(image_path,resize=False):
100
+ """
101
+ Load a 2D grayscale image and convert it to a tensor with dimensions [1, 1, 480, 480].
102
+
103
+ Args:
104
+ image_path (str): Path to the image file.
105
+
106
+ Returns:
107
+ tensor (torch.Tensor): Tensor with shape [1, 1, 480, 480].
108
+ """
109
+ # Load the image as grayscale
110
+ image = Image.open(image_path).convert('L')
111
+
112
+ # Resize to 480x480 if needed
113
+ if resize:
114
+ if image.size != (480, 480):
115
+ image = image.resize((480, 480), Image.BILINEAR)
116
+ else:
117
+ if image.size != (480, 480):
118
+ # 计算可以开始裁剪的最大左上角坐标
119
+ width, height = image.size
120
+ crop_size = 480
121
+ if width < crop_size or height < crop_size:
122
+ raise ValueError(f"Image size {image.size} is smaller than the crop size {crop_size}.")
123
+ max_left = width - crop_size
124
+ max_top = height - crop_size
125
+
126
+ # 随机选择裁剪的左上角坐标
127
+ left = random.randint(0, max(0, max_left))
128
+ top = random.randint(0, max(0, max_top))
129
+
130
+ # 裁剪图像
131
+ image = image.crop((left, top, left + crop_size, top + crop_size))
132
+
133
+
134
+ # Convert to numpy array and normalize to [0, 1]
135
+ img_array = (np.array(image, dtype=np.float32) / 255.0) * 2 -1
136
+
137
+ # Convert to tensor
138
+ tensor = torch.from_numpy(img_array)
139
+
140
+ # Add batch and channel dimensions
141
+ tensor = tensor.unsqueeze(0).unsqueeze(0) # [1, 1, 480, 480]
142
+
143
+ return tensor
retrieval_metric.py ADDED
@@ -0,0 +1,463 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import numpy as np
3
+
4
+ def hard_retrieval(logits, gt_logits, k):
5
+ # treat ratescore > x as gt and calculate recall
6
+
7
+ max_indices = torch.argmax(gt_logits, dim=1)
8
+ gt_matrix = torch.zeros_like(gt_logits)
9
+ gt_matrix[torch.arange(gt_logits.size(0)), max_indices] = 1 # for each row, the position of max element is 1, others 0
10
+
11
+ # gt_matrix = torch.eye(logits.shape[0]).to(logits.device)
12
+
13
+ metrics = {}
14
+ top10 = {}
15
+ gt_ranks = {}
16
+
17
+ # a sample may have multiple gt, find the one with highest logits
18
+ gt_logit = gt_matrix * logits # [0.1, 0.3, 0.2] * [1, 0, 1] --> [0.1, 0, 0.2]
19
+ gt_rank = torch.argmax(gt_logit, dim=1).view(-1, 1) # --> 2
20
+
21
+ ranking = torch.argsort(logits, descending=True) # [0.1, 0.3, 0.2] --> [1, 2, 0] ranking logits along each row
22
+ preds = torch.where(ranking == gt_rank)[1] # [1, 2, 0] == [0] --> [2] where the gt lies in ranks
23
+ preds = preds.detach().cpu().numpy()
24
+
25
+ if isinstance(k, int):
26
+ return np.mean(preds < k)
27
+ else:
28
+ results = []
29
+ for current_k in k:
30
+ results.append(np.mean(preds < current_k))
31
+
32
+ return results
33
+
34
+ def hard_retrieval_exclude_self(logits, gt_logits, index_list, k, return_detail=False):
35
+ # treat ratescore > x as gt and calculate recall
36
+
37
+ if return_detail:
38
+ assert len(k)==1, 'must be a single k when return_detail is True'
39
+
40
+ # Exclude self elements using the provided index_list
41
+ logits = delete_elements_by_row(logits, index_list)
42
+ gt_logits = delete_elements_by_row(gt_logits, index_list)
43
+ logits = logits.detach().cpu()
44
+ gt_logits = gt_logits.detach().cpu()
45
+
46
+ # Ranking the logits in descending order for each row
47
+ ranking = torch.argsort(logits, descending=True)
48
+
49
+ # Define GT as all positions (excluding self) with gt_logits > 0.9
50
+ gt_mask = gt_logits > 0.9
51
+
52
+ # 过滤无正样本的行,只统计有GT的sample
53
+ valid_rows = gt_mask.any(dim=1)
54
+ if not valid_rows.any():
55
+ return -1 # 或无正样本时的处理
56
+
57
+ ranking = ranking[valid_rows]
58
+ gt_mask = gt_mask[valid_rows]
59
+
60
+ if return_detail:
61
+ k = k[0]
62
+ topk = ranking[:, :k]
63
+ # Check for each row if any of the topk indices is a valid GT
64
+
65
+ topk_gt = torch.gather(gt_mask, 1, topk)
66
+ hits = topk_gt.any(dim=1).float().numpy()
67
+ pred_topk_indices = ranking[:, :k].detach().cpu().numpy() # we delete them selves, so need to add 1
68
+ pred_topk_scores = logits.gather(1, ranking[:, :k]).detach().cpu().numpy()
69
+
70
+ gt_topk = torch.topk(gt_logits, k, dim=1)
71
+ gt_topk_indices = gt_topk.indices.detach().cpu().numpy()
72
+ gt_topk_scores = gt_topk.values.detach().cpu().numpy()
73
+
74
+ detail = {
75
+ "recall": [np.mean(hits)],
76
+ "prediction_topk_indices": pred_topk_indices,
77
+ "prediction_topk_scores": pred_topk_scores,
78
+ "gt_topk_indices": gt_topk_indices,
79
+ "gt_topk_scores": gt_topk_scores,
80
+ "row_recall": hits
81
+ }
82
+ return detail
83
+ else:
84
+ results = []
85
+ for current_k in k:
86
+ topk = ranking[:, :current_k]
87
+ topk_gt = torch.gather(gt_mask, 1, topk)
88
+ hits = topk_gt.any(dim=1).float().numpy()
89
+ results.append(np.mean(hits))
90
+ return results
91
+
92
+ def hard_retrieval_exclude_self_return_details(logits, gt_logits, index_list, k, return_detail=False):
93
+ # treat ratescore > x as gt and calculate recall
94
+ # to visualization, visualize retrieval details
95
+
96
+ assert len(k)==1, 'must be a single k when return_detail is True'
97
+
98
+ # Exclude self elements using the provided index_list
99
+ logits = delete_elements_by_row(logits, index_list)
100
+ gt_logits = delete_elements_by_row(gt_logits, index_list)
101
+ logits = logits.detach().cpu()
102
+ gt_logits = gt_logits.detach().cpu()
103
+
104
+ # Ranking the logits in descending order for each row
105
+ ranking = torch.argsort(logits, descending=True)
106
+
107
+ # Define GT as all positions (excluding self) with gt_logits > 0.9
108
+ gt_mask = gt_logits > 0.9
109
+
110
+ k = k[0]
111
+ topk = ranking[:, :k]
112
+ # Check for each row if any of the topk indices is a valid GT
113
+
114
+ topk_gt = torch.gather(gt_mask, 1, topk)
115
+ hits = topk_gt.any(dim=1).float().numpy()
116
+ pred_topk_indices = ranking[:, :k].detach().cpu().numpy() # we delete them selves, so need to add 1
117
+ pred_topk_scores = gt_logits.gather(1, ranking[:, :k]).detach().cpu().numpy()
118
+
119
+ gt_topk = torch.topk(gt_logits, k, dim=1)
120
+ gt_topk_indices = gt_topk.indices.detach().cpu().numpy()
121
+ gt_topk_scores = gt_topk.values.detach().cpu().numpy()
122
+
123
+ detail = {
124
+ "recall": [np.mean(hits)],
125
+ "prediction_topk_indices": pred_topk_indices,
126
+ "prediction_topk_scores": pred_topk_scores,
127
+ "gt_topk_indices": gt_topk_indices,
128
+ "gt_topk_scores": gt_topk_scores,
129
+ "row_recall": hits
130
+ }
131
+ return detail
132
+
133
+ def soft_retrieval(logits, similarity_matrix, k=[1, 5, 10]):
134
+ # calculate avg and upperbound ratescore of image 2 image retrieval results
135
+ # NOTE: do not have to exclude items themselves in logits or similarity_matrix
136
+
137
+ ranking = torch.argsort(logits, descending=True) # [0.1, 0.3, 0.2] --> [1, 2, 0] ranking logits along each row
138
+ pred_score = []
139
+ upperbound_score = []
140
+
141
+ for current_k in k:
142
+ top_ranking = ranking[:, 1:current_k+1] # [n, k] exclude themselves (top 1)
143
+ avg_similarities = torch.zeros(top_ranking.shape[0])
144
+ for i in range(top_ranking.shape[0]):
145
+ top_idx = top_ranking[i].tolist()
146
+ avg_similarities[i] = similarity_matrix[i, top_idx].mean()
147
+ pred_score.append(avg_similarities.mean().item())
148
+
149
+ for current_k in k:
150
+ actual_k = min(current_k + 1, similarity_matrix.size(1))
151
+ topk_values = torch.topk(similarity_matrix, actual_k, dim=1).values # shape: (n_samples, actual_k)
152
+ # 如果实际取出的topk不足以排除 self,则直接使用所有
153
+ if actual_k > 1:
154
+ topk_without_self = topk_values[:, 1:]
155
+ else:
156
+ topk_without_self = topk_values
157
+ upperbound_score.append(topk_without_self.mean().item())
158
+
159
+ return pred_score, upperbound_score
160
+
161
+ def soft_retrieval_uncon(logits, gt_logits, k):
162
+ # treat ratescore > x as gt and calculate recall
163
+
164
+ logits = logits.detach().cpu()
165
+ gt_logits = gt_logits.detach().cpu()
166
+
167
+ # Ranking the logits in descending order for each row
168
+ ranking = torch.argsort(logits, descending=True)
169
+
170
+ # Define GT as all positions (excluding self) with gt_logits > 0.9
171
+ gt_mask = gt_logits > 0.9
172
+
173
+ # 过滤无正样本的行,只统计有GT的sample
174
+ valid_rows = gt_mask.any(dim=1)
175
+ if not valid_rows.any():
176
+ return -1 # 或无正样本时的处理
177
+
178
+ ranking = ranking[valid_rows]
179
+ gt_mask = gt_mask[valid_rows]
180
+
181
+ if isinstance(k, int):
182
+ topk = ranking[:, :k]
183
+ # Check for each row if any of the topk indices is a valid GT
184
+ topk_gt = torch.gather(gt_mask, 1, topk)
185
+ hits = topk_gt.any(dim=1).float().numpy()
186
+ return np.mean(hits)
187
+ else:
188
+ results = []
189
+ for current_k in k:
190
+ topk = ranking[:, :current_k]
191
+ topk_gt = torch.gather(gt_mask, 1, topk)
192
+ hits = topk_gt.any(dim=1).float().numpy()
193
+ results.append(np.mean(hits))
194
+ return results
195
+
196
+ def soft_retrieval_exclude_self(logits, gt_logits, index_list, k):
197
+ # treat ratescore > x as gt and calculate recall
198
+ logits = delete_elements_by_row(logits, index_list)
199
+ gt_logits = delete_elements_by_row(gt_logits, index_list)
200
+ logits = logits.detach().cpu()
201
+ gt_logits = gt_logits.detach().cpu()
202
+
203
+ # Ranking the logits in descending order for each row
204
+ ranking = torch.argsort(logits, descending=True)
205
+
206
+ # Define GT as all positions (excluding self) with gt_logits > 0.9
207
+ gt_mask = gt_logits > 0.9
208
+
209
+ # 过滤无正样本的行,只统计有GT的sample
210
+ valid_rows = gt_mask.any(dim=1)
211
+ if not valid_rows.any():
212
+ return -1 # 或无正样本时的处理
213
+ ranking = ranking[valid_rows]
214
+ gt_mask = gt_mask[valid_rows]
215
+
216
+ if isinstance(k, int):
217
+ topk = ranking[:, :k]
218
+ # Check for each row if any of the topk indices is a valid GT
219
+ topk_gt = torch.gather(gt_mask, 1, topk)
220
+ hits = topk_gt.any(dim=1).float().numpy()
221
+ return np.mean(hits)
222
+ else:
223
+ results = []
224
+ for current_k in k:
225
+ topk = ranking[:, :current_k]
226
+ topk_gt = torch.gather(gt_mask, 1, topk)
227
+ hits = topk_gt.any(dim=1).float().numpy()
228
+ results.append(np.mean(hits))
229
+ print('results:', results)
230
+ return results
231
+
232
+
233
+ def RateScore_retrieval(logits, similarity_matrix, k=[1, 5, 10]):
234
+ # calculate avg and upperbound ratescore of retrieval
235
+ # NOTE: do not have to exclude items themselves in logits or similarity_matrix
236
+
237
+ ranking = torch.argsort(logits, descending=True) # [0.1, 0.3, 0.2] --> [1, 2, 0] ranking logits along each row
238
+ pred_score = []
239
+ upperbound_score = []
240
+
241
+ for current_k in k:
242
+ top_ranking = ranking[:, 1:current_k+1] # [n, k] exclude themselves (top 1)
243
+ avg_similarities = torch.zeros(top_ranking.shape[0])
244
+ for i in range(top_ranking.shape[0]):
245
+ top_idx = top_ranking[i].tolist()
246
+ avg_similarities[i] = similarity_matrix[i, top_idx].mean()
247
+ pred_score.append(avg_similarities.mean().item())
248
+
249
+ for current_k in k:
250
+ actual_k = min(current_k + 1, similarity_matrix.size(1))
251
+ topk_values = torch.topk(similarity_matrix, actual_k, dim=1).values # shape: (n_samples, actual_k)
252
+ # 如果实际取出的topk不足以排除 self,则直接使用所有
253
+ if actual_k > 1:
254
+ topk_without_self = topk_values[:, 1:]
255
+ else:
256
+ topk_without_self = topk_values
257
+ upperbound_score.append(topk_without_self.mean().item())
258
+
259
+ return pred_score, upperbound_score
260
+
261
+
262
+ def compute_ndcg_exclude_self(pred_scores, true_scores, index_list, k=None):
263
+ """
264
+ 计算NDCG。
265
+ 当 k 是一个整数时,计算对应 k 值的 ndcg;当 k 是列表时,分别计算每个 k 的 ndcg,返回一个与 k 列表等长的列表,每个元素是对应的 score。
266
+ 输入的 true_scores 和 pred_scores 为 tensor 类型。
267
+ :param true_scores: 真实相似度矩阵 (n_query, n_samples)
268
+ :param pred_scores: 模型预测的相似度矩阵 (n_query, n_samples)
269
+ :param index_list: 每行需要删除的元素的索引列表
270
+ :param k: 只考虑前 k 个结果。如果为 None,则考虑所有结果;如果为 list,则分别计算每个 k 的 ndcg
271
+ :return: 当 k 为 list 时返回一个 ndcg 值的列表,否则返回单个 ndcg 值
272
+ """
273
+ true_scores = delete_elements_by_row(true_scores, index_list)
274
+ pred_scores = delete_elements_by_row(pred_scores, index_list)
275
+ # 转换为 numpy 数组以便后续排序和计算
276
+ true_scores = true_scores.detach().cpu().numpy()
277
+ pred_scores = pred_scores.detach().cpu().numpy()
278
+ n_query = true_scores.shape[0]
279
+
280
+ if k is None:
281
+ k_list = [true_scores.shape[1]]
282
+ elif isinstance(k, list):
283
+ k_list = k
284
+ else:
285
+ k_list = [k]
286
+
287
+ ndcg_results = []
288
+ for current_k in k_list:
289
+ ndcg_scores = []
290
+ for i in range(n_query):
291
+ true = true_scores[i]
292
+ pred = pred_scores[i]
293
+ pred_rank = np.argsort(pred)[::-1]
294
+ true_sorted_by_pred = true[pred_rank]
295
+
296
+ # 计算 DCG
297
+ if current_k is not None:
298
+ true_sorted_by_pred_k = true_sorted_by_pred[:current_k]
299
+ else:
300
+ true_sorted_by_pred_k = true_sorted_by_pred
301
+ dcg = compute_dcg(true_sorted_by_pred_k)
302
+
303
+ # 计算 IDCG
304
+ ideal_sorted_true = np.sort(true)[::-1]
305
+ if current_k is not None:
306
+ ideal_sorted_true = ideal_sorted_true[:current_k]
307
+ idcg = compute_dcg(ideal_sorted_true)
308
+
309
+ ndcg = dcg / idcg if idcg != 0 else 0
310
+ ndcg_scores.append(ndcg)
311
+ ndcg_results.append(np.mean(ndcg_scores))
312
+
313
+ # 如果原始 k 不是 list,则返回单个值
314
+ if not isinstance(k, list):
315
+ return ndcg_results[0]
316
+ return ndcg_results
317
+
318
+ def compute_ndcg_uncon(pred_scores, true_scores, k=None):
319
+ """
320
+ 计算NDCG。
321
+ 当 k 是一个整数时,计算对应 k 值的 ndcg;当 k 是列表时,分别计算每个 k 的 ndcg,返回一个与 k 列表等长的列表,每个元素是对应的 score。
322
+ 输入的 true_scores 和 pred_scores 为 tensor 类型。
323
+ :param true_scores: 真实相似度矩阵 (n_query, n_samples)
324
+ :param pred_scores: 模型预测的相似度矩阵 (n_query, n_samples)
325
+ :param k: 只考虑前 k 个结果。如果为 None,则考虑所有结果;如果为 list,则分别计算每个 k 的 ndcg
326
+ :return: 当 k 为 list 时返回一个 ndcg 值的列表,否则返回单个 ndcg 值
327
+ """
328
+ # 转换为 numpy 数组以便后续排序和计算
329
+ true_scores = true_scores.detach().cpu().numpy()
330
+ pred_scores = pred_scores.detach().cpu().numpy()
331
+ n_query = true_scores.shape[0]
332
+
333
+ if k is None:
334
+ k_list = [true_scores.shape[1]]
335
+ elif isinstance(k, list):
336
+ k_list = k
337
+ else:
338
+ k_list = [k]
339
+
340
+ ndcg_results = []
341
+ for current_k in k_list:
342
+ ndcg_scores = []
343
+ for i in range(n_query):
344
+ true = true_scores[i]
345
+ pred = pred_scores[i]
346
+ pred_rank = np.argsort(pred)[::-1]
347
+ true_sorted_by_pred = true[pred_rank]
348
+
349
+ # 计算 DCG
350
+ if current_k is not None:
351
+ true_sorted_by_pred_k = true_sorted_by_pred[:current_k]
352
+ else:
353
+ true_sorted_by_pred_k = true_sorted_by_pred
354
+ dcg = compute_dcg(true_sorted_by_pred_k)
355
+
356
+ # 计算 IDCG
357
+ ideal_sorted_true = np.sort(true)[::-1]
358
+ if current_k is not None:
359
+ ideal_sorted_true = ideal_sorted_true[:current_k]
360
+ idcg = compute_dcg(ideal_sorted_true)
361
+
362
+ ndcg = dcg / idcg if idcg != 0 else 0
363
+ ndcg_scores.append(ndcg)
364
+ ndcg_results.append(np.mean(ndcg_scores))
365
+
366
+ # 如果原始 k 不是 list,则返回单个值
367
+ if not isinstance(k, list):
368
+ return ndcg_results[0]
369
+ return ndcg_results
370
+
371
+
372
+ def delete_elements_by_row(matrix, delete_list):
373
+ """
374
+ 根据给定的索引列表,删除每行中指定位置的元素
375
+ :param matrix: 输入的 n*m 维 tensor
376
+ :param delete_list: 一个 n 大小的 list,表示每行需要删除的元素的索引
377
+ :return: 处理后的 n*(m-1) 维 tensor
378
+ """
379
+ n, m = matrix.size()
380
+ result = []
381
+
382
+ for i in range(n):
383
+ row = matrix[i]
384
+ delete_index = delete_list[i]
385
+ # 删除指定位置的元素
386
+ new_row = torch.cat([row[:delete_index], row[delete_index+1:]])
387
+ result.append(new_row)
388
+
389
+ return torch.stack(result)
390
+
391
+ def compute_dcg(scores):
392
+ """计算DCG"""
393
+ return np.sum(scores / np.log2(np.arange(2, len(scores) + 2)))
394
+
395
+ def compute_ndcg(pred_scores, true_scores, index_list, k=None):
396
+ """
397
+ 计算NDCG。
398
+ 当 k 是一个整数时,计算对应 k 值的 ndcg;当 k 是列表时,分别计算每个 k 的 ndcg,返回一个与 k 列表等长的列表,每个元素是对应的 score。
399
+ 输入的 true_scores 和 pred_scores 为 tensor 类型。
400
+ :param true_scores: 真实相似度矩阵 (n_query, n_samples)
401
+ :param pred_scores: 模型预测的相似度矩阵 (n_query, n_samples)
402
+ :param index_list: 每行需要删除的元素的索引列表
403
+ :param k: 只考虑前 k 个结果。如果为 None,则考虑所有结果;如果为 list,则分别计算每个 k 的 ndcg
404
+ :return: 当 k 为 list 时返回一个 ndcg 值的列表,否则返回单个 ndcg 值
405
+ """
406
+ true_scores = delete_elements_by_row(true_scores, index_list)
407
+ pred_scores = delete_elements_by_row(pred_scores, index_list)
408
+ # 转换为 numpy 数组以便后续排序和计算
409
+ true_scores = true_scores.detach().cpu().numpy()
410
+ pred_scores = pred_scores.detach().cpu().numpy()
411
+ n_query = true_scores.shape[0]
412
+
413
+ if isinstance(k, list):
414
+ k_list = k
415
+ else:
416
+ k_list = [k]
417
+
418
+ ndcg_results = []
419
+ for current_k in k_list:
420
+ ndcg_scores = []
421
+ for i in range(n_query):
422
+ true = true_scores[i]
423
+ pred = pred_scores[i]
424
+ pred_rank = np.argsort(pred)[::-1]
425
+ true_sorted_by_pred = true[pred_rank]
426
+
427
+ # 计算 DCG
428
+ if current_k is not None:
429
+ true_sorted_by_pred_k = true_sorted_by_pred[:current_k]
430
+ else:
431
+ true_sorted_by_pred_k = true_sorted_by_pred
432
+ dcg = compute_dcg(true_sorted_by_pred_k)
433
+
434
+ # 计算 IDCG
435
+ ideal_sorted_true = np.sort(true)[::-1]
436
+ if current_k is not None:
437
+ ideal_sorted_true = ideal_sorted_true[:current_k]
438
+ idcg = compute_dcg(ideal_sorted_true)
439
+
440
+ ndcg = dcg / idcg if idcg != 0 else 0
441
+ ndcg_scores.append(ndcg)
442
+ ndcg_results.append(np.mean(ndcg_scores))
443
+
444
+ # 如果原始 k 不是 list,则返回单个值
445
+ if not isinstance(k, list):
446
+ return ndcg_results[0]
447
+ return ndcg_results
448
+
449
+
450
+
451
+ if __name__ == '__main__':
452
+ logits = torch.rand(4, 4)
453
+ logits.fill_diagonal_(1)
454
+ gt_logits = torch.rand(4, 4)
455
+ gt_logits.fill_diagonal_(1)
456
+ index = [0, 1, 2, 3]
457
+
458
+ print('logits:', logits)
459
+ print('gt_logits:', gt_logits)
460
+
461
+ ndcg = compute_ndcg(gt_logits, logits, index, k=[1, 3])
462
+
463
+ print(ndcg)
run_CXR_CT_train.py ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import argparse
3
+ import torch
4
+ import wandb
5
+ from datetime import datetime
6
+
7
+ from transformer_maskgit.transformer_maskgit.ctvit import CTViT
8
+ from transformers import AutoTokenizer, AutoModel
9
+
10
+ from Rad_IR.radir import RADIR
11
+ from Rad_IR.radir.data.hmclip_mimic_box import HyCoClipMimicBoxDataset_JPG
12
+
13
+ from scripts.RadIR_Trainer_CXR_CT import RadIRTrainer
14
+
15
+
16
+ # 当前 ablation 标识,改 loss 时同步更新此处
17
+ ABLATION_TAG = "no_i2t_ce"
18
+
19
+
20
+ def build_run_name(args):
21
+ if args.run_name:
22
+ return args.run_name
23
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
24
+ return f"CXRCT_stage1_{ABLATION_TAG}_{timestamp}"
25
+
26
+
27
+ def build_results_folder(args, run_name):
28
+ if args.results_folder:
29
+ return args.results_folder
30
+ return os.path.join("./outputs/ablation", run_name)
31
+
32
+
33
+ # --------------------------------------------------------
34
+ # Args
35
+ # --------------------------------------------------------
36
+ def get_args():
37
+ parser = argparse.ArgumentParser("RADIR Training with Trainer")
38
+
39
+ parser.add_argument('--resume', type=str, default=None, help="checkpoint 路径,填了就断点续训")
40
+ parser.add_argument('--batch', type=int, default=16)
41
+ parser.add_argument('--num_train_steps', type=int, default=100000)
42
+ parser.add_argument('--warmup_steps', type=int, default=1000)
43
+
44
+ parser.add_argument('--lr', type=float, default=5e-5)
45
+ parser.add_argument('--wd', type=float, default=0.0)
46
+
47
+ parser.add_argument('--channels', type=int, default=1)
48
+ parser.add_argument('--height', type=int, default=224)
49
+ parser.add_argument('--width', type=int, default=224)
50
+
51
+ parser.add_argument('--modal_embedding', action='store_true')
52
+
53
+ parser.add_argument(
54
+ '--run_name',
55
+ type=str,
56
+ default=None,
57
+ help='实验名称;默认 CXRCT_stage1_{ablation_tag}_{timestamp}',
58
+ )
59
+ parser.add_argument(
60
+ '--results_folder',
61
+ type=str,
62
+ default=None,
63
+ help='输出目录;默认 ./outputs/ablation/{run_name}',
64
+ )
65
+ parser.add_argument(
66
+ '--wandb_project',
67
+ type=str,
68
+ default='RadIR_Ablation',
69
+ help='wandb project 名称',
70
+ )
71
+ parser.add_argument(
72
+ '--wandb_group',
73
+ type=str,
74
+ default='ablation_no_i2t_ce',
75
+ help='wandb group 名称',
76
+ )
77
+
78
+ # Dataset
79
+ parser.add_argument(
80
+ '--dataset_root',
81
+ type=str,
82
+ default="/testessfs10/users/zeyu.zhang/HMClip/RadIR/RadIR/dataset",
83
+ )
84
+ parser.add_argument(
85
+ '--matched_dir',
86
+ type=str,
87
+ default="/testessfs10/users/zeyu.zhang/HMClip/RadIR/RadIR/dataset/processed/matched",
88
+ )
89
+
90
+ # Local HF model
91
+ parser.add_argument(
92
+ '--model_path',
93
+ type=str,
94
+ default="/testessfs10/users/zeyu.zhang/HMClip/RadIR/RadIR/hf_models/microsoft/BiomedVLP-CXR-BERT-specialized",
95
+ )
96
+
97
+ parser.add_argument('--num_workers', type=int, default=4)
98
+
99
+ return parser.parse_args()
100
+
101
+
102
+ # --------------------------------------------------------
103
+ # Main
104
+ # --------------------------------------------------------
105
+ def main():
106
+ args = get_args()
107
+ run_name = build_run_name(args)
108
+ args.results_folder = build_results_folder(args, run_name)
109
+ args.run_name = run_name
110
+
111
+ if not torch.cuda.is_available():
112
+ raise RuntimeError("需要 GPU(CUDA)")
113
+
114
+ os.makedirs(args.results_folder, exist_ok=True)
115
+
116
+ print(f">>> Run name: {run_name}")
117
+ print(f">>> Results folder: {args.results_folder}")
118
+
119
+ # --------------------------------------------------------
120
+ # Load HF Text Encoder
121
+ # --------------------------------------------------------
122
+ print(">>> Loading tokenizer & text encoder...")
123
+ tokenizer = AutoTokenizer.from_pretrained(
124
+ args.model_path, trust_remote_code=True, local_files_only=True
125
+ )
126
+
127
+ text_model = AutoModel.from_pretrained(
128
+ args.model_path, trust_remote_code=True, local_files_only=True
129
+ )
130
+
131
+ # --------------------------------------------------------
132
+ # Load Image Encoder
133
+ # --------------------------------------------------------
134
+ print(">>> Loading CTViT...")
135
+ image_encoder = CTViT(
136
+ dim=768,
137
+ codebook_size=8192,
138
+ image_size=args.height,
139
+ patch_size=16,
140
+ temporal_patch_size=10,
141
+ spatial_depth=8,
142
+ temporal_depth=6,
143
+ cls_depth=4,
144
+ dim_head=32,
145
+ heads=8,
146
+ channels=args.channels,
147
+ )
148
+
149
+ # --------------------------------------------------------
150
+ # Build RADIR
151
+ # --------------------------------------------------------
152
+ print(">>> Building RADIR...")
153
+ radir = RADIR(
154
+ tokenizer=tokenizer,
155
+ image_encoder=image_encoder,
156
+ text_encoder=text_model,
157
+ dim_text=768,
158
+ dim_image=512,
159
+ dim_latent=512,
160
+ use_mlm=False,
161
+ use_all_token_embeds=False,
162
+ )
163
+
164
+ # --------------------------------------------------------
165
+ # Dataset
166
+ # --------------------------------------------------------
167
+ print(">>> Loading datasets...")
168
+ train_dataset = HyCoClipMimicBoxDataset_JPG(
169
+ split="train",
170
+ tokenizer=tokenizer,
171
+ dataset_root=args.dataset_root,
172
+ cig_matched_dir=args.matched_dir,
173
+ caption_file="train.jsonl",
174
+ transform=None,
175
+ )
176
+
177
+ valid_dataset = HyCoClipMimicBoxDataset_JPG(
178
+ split="valid",
179
+ tokenizer=tokenizer,
180
+ dataset_root=args.dataset_root,
181
+ cig_matched_dir=args.matched_dir,
182
+ caption_file="valid.jsonl",
183
+ transform=None,
184
+ )
185
+
186
+ if int(os.environ.get("RANK", 0)) == 0:
187
+ wandb.init(
188
+ project=args.wandb_project,
189
+ name=run_name,
190
+ group=args.wandb_group,
191
+ tags=["stage1", "CXRCT", "ablation", ABLATION_TAG],
192
+ dir=args.results_folder,
193
+ config=vars(args),
194
+ )
195
+
196
+ # --------------------------------------------------------
197
+ # Trainer
198
+ # --------------------------------------------------------
199
+ print(">>> Building Trainer...")
200
+ trainer = RadIRTrainer(
201
+ RADIR=radir,
202
+ tokenizer=tokenizer,
203
+ train_dataset=train_dataset,
204
+ valid_dataset=valid_dataset,
205
+ num_train_steps=args.num_train_steps,
206
+ warmup_steps=args.warmup_steps,
207
+ batch_size=args.batch,
208
+ lr=args.lr,
209
+ wd=args.wd,
210
+ results_folder=args.results_folder,
211
+ num_workers=args.num_workers,
212
+ modal_embedding=args.modal_embedding,
213
+ debug=False,
214
+ )
215
+
216
+ # --------------------------------------------------------
217
+ # Start Training
218
+ # --------------------------------------------------------
219
+ resume_path = None
220
+ if args.resume is not None:
221
+ if args.resume == "auto":
222
+ resume_path = os.path.join(args.results_folder, "radir_last.pt")
223
+ else:
224
+ resume_path = args.resume
225
+
226
+ print("✅ Start Training...")
227
+ trainer.train_model(resume_path=resume_path)
228
+
229
+
230
+ if __name__ == "__main__":
231
+ main()
stage1_eva_i2t.py ADDED
@@ -0,0 +1,393 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ """
4
+ I2T / T2I evaluation with RaTEScore-aligned GT.
5
+
6
+ Uses valid.jsonl for image-text pairs and maps each sample to val_caption.csv
7
+ row indices, then slices val_ratescore.npy into a sub-matrix aligned with the
8
+ eval set order (same logic as RadIR evaluate_uncon soft_label path).
9
+ """
10
+
11
+ import argparse
12
+ import json
13
+ import os
14
+
15
+ import numpy as np
16
+ import pandas as pd
17
+ import torch
18
+ from torch.utils.data import DataLoader, Subset
19
+ from transformers import AutoModel, AutoTokenizer
20
+
21
+ from Rad_IR.radir import lorentz as L
22
+ from Rad_IR.radir.RadIR import RADIR, l2norm
23
+ from Rad_IR.radir.data.hmclip_mimic_box import HyCoClipMimicBoxDataset_JPG, collate_hmclip_box
24
+ from retrieval_metric import compute_ndcg_uncon, soft_retrieval_uncon
25
+ from transformer_maskgit.transformer_maskgit.ctvit import CTViT
26
+
27
+ ROOT = os.path.dirname(os.path.abspath(__file__))
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # RaTEScore index alignment (valid.jsonl -> val_caption.csv)
32
+ # ---------------------------------------------------------------------------
33
+
34
+ def study_key_from_csv_path(file_path: str) -> str:
35
+ """/p10/p10190940/s51351116.txt -> p10/p10190940/s51351116"""
36
+ return os.path.splitext(file_path.lstrip("/"))[0]
37
+
38
+
39
+ def study_key_from_jsonl_img_path(img_path: str) -> str:
40
+ """dataset/p10/p10190940/s51351116/xxx.jpg -> p10/p10190940/s51351116"""
41
+ p = img_path.replace("\\", "/")
42
+ if p.startswith("dataset/"):
43
+ p = p[len("dataset/") :]
44
+ parts = p.split("/")
45
+ if len(parts) < 3:
46
+ raise ValueError(f"Cannot parse study key from img_path: {img_path}")
47
+ return "/".join(parts[:3])
48
+
49
+
50
+ def build_eval_index_mapping(
51
+ jsonl_path: str,
52
+ csv_path: str,
53
+ dedupe_study: bool = False,
54
+ ):
55
+ """
56
+ Map each eval row in jsonl to a row index in val_caption.csv / val_ratescore.npy.
57
+
58
+ Returns:
59
+ keep_row_indices: indices into jsonl/dataset to evaluate (in jsonl order)
60
+ csv_indices: parallel list of val_caption.csv row indices for RaTEScore slice
61
+ stats: dict with alignment diagnostics
62
+ """
63
+ df = pd.read_csv(csv_path)
64
+ csv_keys = [study_key_from_csv_path(p) for p in df["File Path"]]
65
+ key_to_csv_idx = {k: i for i, k in enumerate(csv_keys)}
66
+
67
+ jsonl_keys = []
68
+ with open(jsonl_path, encoding="utf-8") as f:
69
+ for line in f:
70
+ item = json.loads(line.strip())
71
+ jsonl_keys.append(study_key_from_jsonl_img_path(item["img_path"]))
72
+
73
+ keep_row_indices = []
74
+ csv_indices = []
75
+ seen_study = set()
76
+ unmatched = []
77
+
78
+ for row_i, key in enumerate(jsonl_keys):
79
+ if key not in key_to_csv_idx:
80
+ unmatched.append((row_i, key))
81
+ continue
82
+ if dedupe_study and key in seen_study:
83
+ continue
84
+ seen_study.add(key)
85
+ keep_row_indices.append(row_i)
86
+ csv_indices.append(key_to_csv_idx[key])
87
+
88
+ stats = {
89
+ "jsonl_rows": len(jsonl_keys),
90
+ "csv_rows": len(csv_keys),
91
+ "eval_rows": len(keep_row_indices),
92
+ "unique_studies": len(seen_study),
93
+ "unmatched_jsonl": len(unmatched),
94
+ "csv_not_in_jsonl": sum(1 for k in csv_keys if k not in set(jsonl_keys)),
95
+ "dedupe_study": dedupe_study,
96
+ }
97
+ if unmatched:
98
+ stats["unmatched_examples"] = unmatched[:5]
99
+
100
+ return keep_row_indices, csv_indices, stats
101
+
102
+
103
+ def load_aligned_ratescore_gt(gt_path: str, csv_indices: list[int]) -> torch.Tensor:
104
+ """Slice full RaTEScore matrix to match eval sample order."""
105
+ gt = np.load(gt_path)
106
+ idx = np.asarray(csv_indices, dtype=np.int64)
107
+ gt_sub = gt[np.ix_(idx, idx)].astype(np.float32)
108
+ return torch.from_numpy(gt_sub / 100.0)
109
+
110
+
111
+ # ---------------------------------------------------------------------------
112
+ # Feature extraction (aligned with RadIR.forward is_condition=False global path)
113
+ # ---------------------------------------------------------------------------
114
+
115
+ def preprocess_image_like_forward(image: torch.Tensor) -> torch.Tensor:
116
+ if image.ndim == 3:
117
+ image = image.unsqueeze(0)
118
+ if image.ndim == 4:
119
+ if image.shape[1] != 1:
120
+ image = image.mean(dim=1, keepdim=True)
121
+ elif image.ndim == 5:
122
+ if image.shape[1] != 1:
123
+ image = image.mean(dim=1, keepdim=True)
124
+ else:
125
+ raise ValueError(f"Unexpected image ndim={image.ndim}, shape={tuple(image.shape)}")
126
+ return image
127
+
128
+
129
+ def extract_hyperbolic_features(model, dataloader, device, modal_embedding=False):
130
+ """Extract hyperbolic features (project_img/txt after l2norm global encodings)."""
131
+ all_hyp_img, all_hyp_txt = [], []
132
+
133
+ model.eval()
134
+ with torch.no_grad():
135
+ for batch in dataloader:
136
+ imgs = preprocess_image_like_forward(batch["imgs"].to(device, non_blocking=True))
137
+
138
+ enc_image = model.visual_transformer(
139
+ imgs,
140
+ return_encoded_tokens=True,
141
+ modal_embedding=modal_embedding,
142
+ modal_indexs=None,
143
+ is_condition=False,
144
+ )
145
+ if enc_image.dim() == 2:
146
+ image_global = l2norm(enc_image)
147
+ elif enc_image.dim() == 3:
148
+ image_global = l2norm(enc_image[:, 0, :])
149
+ else:
150
+ raise ValueError(f"Unexpected visual feature shape: {enc_image.shape}")
151
+
152
+ text_embeddings = model.text_transformer(
153
+ input_ids=batch["caption_ids"].to(device, non_blocking=True),
154
+ token_type_ids=batch["token_type_ids"].to(device, non_blocking=True),
155
+ attention_mask=batch["attention_mask"].to(device, non_blocking=True),
156
+ )
157
+ enc_text = text_embeddings[0]
158
+ text_global = enc_text[:, 0, :]
159
+
160
+ all_hyp_img.append(model.project_img(image_global).cpu())
161
+ all_hyp_txt.append(model.project_txt(text_global).cpu())
162
+
163
+ return torch.cat(all_hyp_img, dim=0), torch.cat(all_hyp_txt, dim=0)
164
+
165
+
166
+ # ---------------------------------------------------------------------------
167
+ # Metrics
168
+ # ---------------------------------------------------------------------------
169
+
170
+ def paired_recall(sim_matrix: torch.Tensor, ks):
171
+ """Hard paired recall: query i should retrieve candidate i (diagonal)."""
172
+ ranking = torch.argsort(sim_matrix, dim=1, descending=True)
173
+ gt = torch.arange(sim_matrix.size(0), device=sim_matrix.device)
174
+ results = {}
175
+ for k in ks:
176
+ hits = (ranking[:, :k] == gt.unsqueeze(1)).any(dim=1).float()
177
+ results[k] = hits.mean().item()
178
+ return results
179
+
180
+
181
+ def print_metric_block(title, ks, paired_r, soft_r, ndcg, scale_pct=True):
182
+ print(f"\n--- {title} ---")
183
+ for k in ks:
184
+ sr = soft_r[k] if isinstance(soft_r, dict) else soft_r[ks.index(k)]
185
+ if sr == -1:
186
+ sr_str = "N/A"
187
+ else:
188
+ sr_str = f"{sr * 100:.2f}%" if scale_pct else f"{sr:.4f}"
189
+ nd = ndcg[ks.index(k)]
190
+ nd_str = f"{nd * 100:.2f}" if scale_pct else f"{nd:.4f}"
191
+ print(
192
+ f"@{k:>3d} Paired_R={paired_r[k] * 100:.2f}% "
193
+ f"Soft_R={sr_str} NDCG={nd_str}"
194
+ )
195
+
196
+
197
+ def evaluate_i2t_t2i(sim_i2t, gt_ratescore, ks):
198
+ """Run RadIR-style I2T metrics with RaTEScore GT matrix."""
199
+ sim_t2i = sim_i2t.t()
200
+ gt = gt_ratescore.to(sim_i2t.device)
201
+
202
+ paired_i2t = paired_recall(sim_i2t, ks)
203
+ paired_t2i = paired_recall(sim_t2i, ks)
204
+
205
+ soft_i2t = soft_retrieval_uncon(sim_i2t, gt, k=ks)
206
+ soft_t2i = soft_retrieval_uncon(sim_t2i, gt, k=ks)
207
+ if not isinstance(soft_i2t, list):
208
+ soft_i2t = [soft_i2t]
209
+ soft_t2i = [soft_t2i]
210
+
211
+ ndcg_i2t = compute_ndcg_uncon(sim_i2t, gt, k=ks)
212
+ ndcg_t2i = compute_ndcg_uncon(sim_t2i, gt, k=ks)
213
+
214
+ soft_i2t = dict(zip(ks, soft_i2t))
215
+ soft_t2i = dict(zip(ks, soft_t2i))
216
+
217
+ return {
218
+ "i2t": {"paired_r": paired_i2t, "soft_r": soft_i2t, "ndcg": ndcg_i2t},
219
+ "t2i": {"paired_r": paired_t2i, "soft_r": soft_t2i, "ndcg": ndcg_t2i},
220
+ }
221
+
222
+
223
+ # ---------------------------------------------------------------------------
224
+ # Main
225
+ # ---------------------------------------------------------------------------
226
+
227
+ def main():
228
+ parser = argparse.ArgumentParser(
229
+ description="I2T/T2I evaluation with RaTEScore-aligned GT (valid.jsonl subset)"
230
+ )
231
+ parser.add_argument("--device", default="cuda", type=str)
232
+ parser.add_argument(
233
+ "--model_path",
234
+ default=os.path.join(
235
+ ROOT, "hf_models/microsoft/BiomedVLP-CXR-BERT-specialized"
236
+ ),
237
+ )
238
+ parser.add_argument("--radir_ckpt", default=os.path.join(ROOT, "outputs/ablation/CXRCT_stage1_no_cross_text_entail_20260703_153255/radir_best.pt"))
239
+ parser.add_argument("--dataset_root", default=os.path.join(ROOT, "dataset"))
240
+ parser.add_argument("--caption_file", default="valid.jsonl")
241
+ parser.add_argument(
242
+ "--csv_file",
243
+ default=os.path.join(ROOT, "dataset/val_caption.csv"),
244
+ help="Original RadIR caption list (defines val_ratescore row order)",
245
+ )
246
+ parser.add_argument(
247
+ "--gt_path",
248
+ default=os.path.join(ROOT, "dataset/val_ratescore.npy"),
249
+ )
250
+ parser.add_argument("--cig_matched_dir", default="dataset/processed/matched")
251
+ parser.add_argument("--split", default="valid", type=str)
252
+ parser.add_argument("--batch_size", default=16, type=int)
253
+ parser.add_argument("--num_workers", default=8, type=int)
254
+ parser.add_argument("--imsize", default=224, type=int)
255
+ parser.add_argument("--max_words", default=128, type=int)
256
+ parser.add_argument(
257
+ "--dedupe_study",
258
+ action="store_true",
259
+ help="Keep one image per study (~2808). Default: all jsonl rows (~3120).",
260
+ )
261
+ parser.add_argument(
262
+ "--modal_embedding",
263
+ action="store_true",
264
+ help="Match training when --modal_embedding was enabled",
265
+ )
266
+ parser.add_argument(
267
+ "--ks",
268
+ type=int,
269
+ nargs="+",
270
+ default=[1, 5, 10, 20, 50, 100],
271
+ )
272
+ args = parser.parse_args()
273
+ device = args.device
274
+
275
+ jsonl_path = os.path.join(args.dataset_root, args.caption_file)
276
+ keep_rows, csv_indices, stats = build_eval_index_mapping(
277
+ jsonl_path, args.csv_file, dedupe_study=args.dedupe_study
278
+ )
279
+ if stats["unmatched_jsonl"]:
280
+ raise RuntimeError(
281
+ f"{stats['unmatched_jsonl']} jsonl rows have no match in {args.csv_file}. "
282
+ f"Examples: {stats.get('unmatched_examples')}"
283
+ )
284
+
285
+ print("===== RaTEScore alignment =====")
286
+ for k, v in stats.items():
287
+ if k != "unmatched_examples":
288
+ print(f" {k}: {v}")
289
+
290
+ gt_ratescore = load_aligned_ratescore_gt(args.gt_path, csv_indices)
291
+ print(f" gt_sub shape: {tuple(gt_ratescore.shape)}")
292
+
293
+ tokenizer = AutoTokenizer.from_pretrained(
294
+ args.model_path, trust_remote_code=True, local_files_only=True
295
+ )
296
+ text_model = AutoModel.from_pretrained(
297
+ args.model_path, trust_remote_code=True, local_files_only=True
298
+ )
299
+ image_encoder = CTViT(
300
+ dim=768,
301
+ codebook_size=8192,
302
+ image_size=args.imsize,
303
+ patch_size=16,
304
+ temporal_patch_size=10,
305
+ spatial_depth=8,
306
+ temporal_depth=6,
307
+ cls_depth=4,
308
+ dim_head=32,
309
+ heads=8,
310
+ channels=1,
311
+ )
312
+ model = RADIR(
313
+ tokenizer=tokenizer,
314
+ image_encoder=image_encoder,
315
+ text_encoder=text_model,
316
+ dim_text=768,
317
+ dim_image=768,
318
+ dim_latent=768,
319
+ use_mlm=False,
320
+ use_all_token_embeds=False,
321
+ )
322
+ print(f"\nLoading checkpoint: {args.radir_ckpt}")
323
+ model.load(args.radir_ckpt)
324
+ model.to(device)
325
+
326
+ full_dataset = HyCoClipMimicBoxDataset_JPG(
327
+ split=args.split,
328
+ tokenizer=tokenizer,
329
+ dataset_root=args.dataset_root,
330
+ cig_matched_dir=args.cig_matched_dir,
331
+ caption_file=args.caption_file,
332
+ imsize=args.imsize,
333
+ max_words=args.max_words,
334
+ transform=None,
335
+ )
336
+ dataset = Subset(full_dataset, keep_rows)
337
+ dataloader = DataLoader(
338
+ dataset,
339
+ batch_size=args.batch_size,
340
+ shuffle=False,
341
+ num_workers=args.num_workers,
342
+ pin_memory=True,
343
+ collate_fn=collate_hmclip_box,
344
+ )
345
+ print(f"\nEval samples = {len(dataset)} (from {len(full_dataset)} jsonl rows)")
346
+
347
+ hyp_img, hyp_txt = extract_hyperbolic_features(
348
+ model,
349
+ dataloader,
350
+ device,
351
+ modal_embedding=args.modal_embedding,
352
+ )
353
+
354
+ gt = gt_ratescore.to(device)
355
+ ks = args.ks
356
+
357
+ curv = model.get_curv()
358
+ sim_i2t = -L.pairwise_dist(
359
+ hyp_img.to(device), hyp_txt.to(device), curv
360
+ ).float()
361
+
362
+ print(f"\n===== Hyperbolic I2T/T2I (curv={curv.item():.4f}) =====")
363
+ res = evaluate_i2t_t2i(sim_i2t, gt, ks)
364
+ print_metric_block(
365
+ "Image -> Text",
366
+ ks,
367
+ res["i2t"]["paired_r"],
368
+ res["i2t"]["soft_r"],
369
+ res["i2t"]["ndcg"],
370
+ )
371
+ print_metric_block(
372
+ "Text -> Image",
373
+ ks,
374
+ res["t2i"]["paired_r"],
375
+ res["t2i"]["soft_r"],
376
+ res["t2i"]["ndcg"],
377
+ )
378
+ print("\n Summary (mean I2T+T2I):")
379
+ for i, k in enumerate(ks):
380
+ pr = (res["i2t"]["paired_r"][k] + res["t2i"]["paired_r"][k]) / 2
381
+ nd = (res["i2t"]["ndcg"][i] + res["t2i"]["ndcg"][i]) / 2
382
+ si = res["i2t"]["soft_r"][k]
383
+ st = res["t2i"]["soft_r"][k]
384
+ sr = (si + st) / 2 if si != -1 and st != -1 else -1
385
+ sr_str = f"{sr * 100:.2f}%" if sr != -1 else "N/A"
386
+ print(
387
+ f" @{k:>3d} Paired_R={pr * 100:.2f}% "
388
+ f"Soft_R={sr_str} NDCG={nd * 100:.2f}"
389
+ )
390
+
391
+
392
+ if __name__ == "__main__":
393
+ main()
stage1_eva_t2t.py ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import torch
3
+ import torch.nn.functional as F
4
+ from torch.utils.data import Dataset, DataLoader
5
+ from PIL import Image
6
+ import numpy as np
7
+ import pandas as pd
8
+ from tqdm import tqdm
9
+
10
+ from Rad_IR.radir.RadIR import RADIR
11
+ from transformer_maskgit.transformer_maskgit.ctvit import CTViT
12
+ from transformers import AutoTokenizer, AutoModel
13
+
14
+ from retrieval_metric import hard_retrieval_exclude_self, compute_ndcg_exclude_self
15
+
16
+
17
+ # =========================================================
18
+ # Dataset — strictly follow CSV order
19
+ # =========================================================
20
+ class CXRStage1EvalDataset(Dataset):
21
+ def __init__(self, csv_file, dataset_root, imsize=224):
22
+ df = pd.read_csv(csv_file)
23
+
24
+ # ⚠️ MUST MATCH RaTEScore ORDER
25
+ self.paths = df["File Path"].tolist()
26
+ self.dataset_root = dataset_root
27
+ self.imsize = imsize
28
+
29
+ def __len__(self):
30
+ return len(self.paths)
31
+
32
+ def __getitem__(self, idx):
33
+ path = self.paths[idx]
34
+
35
+ # 不管是不是绝对路径,都按相对路径处理
36
+ rel_path = path.lstrip("/")
37
+
38
+ # 拼 dataset_root
39
+ full_path = os.path.join(self.dataset_root, rel_path)
40
+
41
+ # 去掉 .txt,得到目录路径
42
+ folder = os.path.splitext(full_path)[0]
43
+
44
+ # 找目录里图片(png/jpg/jpeg)
45
+ imgs = [
46
+ f for f in os.listdir(folder)
47
+ if f.lower().endswith((".png", ".jpg", ".jpeg"))
48
+ ]
49
+ assert len(imgs) > 0, f"No image found in: {folder}"
50
+
51
+ img_path = os.path.join(folder, imgs[0])
52
+
53
+ img = Image.open(img_path).convert("L")
54
+ img = np.array(img, dtype=np.float32) / 255.0
55
+ img = torch.from_numpy(img).unsqueeze(0) # [1, H, W]
56
+
57
+ img = torch.nn.functional.interpolate(
58
+ img.unsqueeze(0), # [1, 1, H, W]
59
+ size=(self.imsize, self.imsize),
60
+ mode="bilinear",
61
+ align_corners=False
62
+ ).squeeze(0) # [1, imsize, imsize]
63
+
64
+ img = img * 2.0 - 1.0 # [-1, 1]
65
+
66
+ return img, idx
67
+
68
+
69
+ # =========================================================
70
+ # Extract Stage-1 features
71
+ # =========================================================
72
+ def extract_image_features(model, dataloader, device):
73
+ all_feats = []
74
+
75
+ model.eval()
76
+ with torch.no_grad():
77
+ for imgs, _ in tqdm(dataloader):
78
+ imgs = imgs.to(device) # [B, 1, H, W]
79
+
80
+ enc = model.visual_transformer(
81
+ imgs,
82
+ return_encoded_tokens=True,
83
+ modal_embedding=False,
84
+ modal_indexs=None,
85
+ is_condition=False
86
+ )
87
+
88
+ latent = model.to_visual_latent(enc)
89
+
90
+ # 保证拿到的是 [B, D] 特征
91
+ if latent.dim() == 3:
92
+ # [B, T, D] -> token 维平均
93
+ feat = latent.mean(dim=1) # [B, D]
94
+ elif latent.dim() == 2:
95
+ feat = latent # [B, D]
96
+ else:
97
+ raise ValueError(f"Unexpected latent shape: {latent.shape}")
98
+
99
+ # 按特征维做归一化(cosine)
100
+ feat = F.normalize(feat, dim=-1) # [B, D]
101
+
102
+ all_feats.append(feat)
103
+
104
+ # cat 之后应该是 [N, D]
105
+ feats = torch.cat(all_feats, dim=0)
106
+ return feats
107
+
108
+
109
+ # =========================================================
110
+ # MAIN
111
+ # =========================================================
112
+ def main():
113
+ device = "cuda"
114
+
115
+ # ================= Model Components ==================
116
+ MODEL_PATH = "/testessfs10/users/zeyu.zhang/HMClip/RadIR/RadIR/hf_models/microsoft/BiomedVLP-CXR-BERT-specialized"
117
+
118
+ tokenizer = AutoTokenizer.from_pretrained(
119
+ MODEL_PATH, trust_remote_code=True, local_files_only=True
120
+ )
121
+ text_model = AutoModel.from_pretrained(
122
+ MODEL_PATH, trust_remote_code=True, local_files_only=True
123
+ )
124
+
125
+ image_encoder = CTViT(
126
+ dim=768,
127
+ codebook_size=8192,
128
+ image_size=224,
129
+ patch_size=16,
130
+ temporal_patch_size=10,
131
+ spatial_depth=8,
132
+ temporal_depth=6,
133
+ cls_depth=4,
134
+ dim_head=32,
135
+ heads=8,
136
+ channels=1
137
+ )
138
+
139
+ print("Loading RADIR...")
140
+ model = RADIR(
141
+ tokenizer=tokenizer,
142
+ image_encoder=image_encoder,
143
+ text_encoder=text_model,
144
+ dim_text=768,
145
+ dim_image=768,
146
+ dim_latent=768,
147
+ use_mlm=False,
148
+ use_all_token_embeds=False
149
+ )
150
+
151
+ model.load("outputs/ablation/CXRCT_stage1_no_cross_text_entail_20260703_153255")
152
+ model.to(device)
153
+
154
+ # ================= Dataset ==================
155
+ dataset = CXRStage1EvalDataset(
156
+ csv_file="dataset/val_caption.csv",
157
+ dataset_root="/testessfs10/users/zeyu.zhang/HMClip/RadIR/RadIR/dataset",
158
+ imsize=224
159
+ )
160
+
161
+ dataloader = DataLoader(
162
+ dataset,
163
+ batch_size=16,
164
+ shuffle=False,
165
+ num_workers=8,
166
+ pin_memory=True
167
+ )
168
+
169
+ print("Eval Images =", len(dataset))
170
+
171
+ # ================= Features ==================
172
+ feats = extract_image_features(model, dataloader, device)
173
+ print("Feature shape:", feats.shape) # 期望: [3185, 512]
174
+
175
+ # 使用 mT 而不是 T(避免 warning)
176
+ logits = feats @ feats.mT # [N, N]
177
+ print("Logits shape:", logits.shape)
178
+
179
+ # ================= Load GT ==================
180
+ GT_PATH = "dataset/val_ratescore.npy"
181
+ gt_logits = torch.from_numpy(np.load(GT_PATH)).float().to(device)
182
+
183
+ # 关键:RaTEScore 是 0–100,这里归一化到 0–1,
184
+ # 这样 retrieval_metric 里用的阈值 >0.9 才等价于 “score >= 90”
185
+ gt_logits = gt_logits / 100.0
186
+
187
+ print("GT shape:", gt_logits.shape)
188
+
189
+ assert gt_logits.shape == logits.shape, \
190
+ f"GT {gt_logits.shape} != logits {logits.shape}"
191
+
192
+ # ================= Metrics ==================
193
+ Ks = [5, 10, 50, 100]
194
+ index_list = list(range(len(dataset))) # 每行自己的 index,用于排除 self
195
+
196
+ # 论文对应的 Image2Image Retrieval:排除 self + RaTEScore>0.9 为正样本
197
+
198
+ recall = hard_retrieval_exclude_self(
199
+ logits, gt_logits, index_list, k=Ks
200
+ )
201
+
202
+
203
+ ndcg = compute_ndcg_exclude_self(
204
+
205
+ logits, gt_logits, index_list, k=Ks
206
+ )
207
+
208
+ print("\n===== Stage-1 (Unconditional CXR Retrieval) =====")
209
+ for k, r in zip(Ks, recall):
210
+ print(f"Recall@{k}: {r:.4f}")
211
+
212
+ for k, n in zip(Ks, ndcg):
213
+ print(f"NDCG@{k}: {n:.4f}")
214
+
215
+ # ============ Random baseline sanity check ============
216
+ rand_logits = torch.randn_like(logits)
217
+
218
+ recall_rand = hard_retrieval_exclude_self(
219
+ rand_logits, gt_logits, index_list, k=Ks
220
+ )
221
+ ndcg_rand = compute_ndcg_exclude_self(
222
+ rand_logits, gt_logits, index_list, k=Ks
223
+ )
224
+
225
+ i = 10
226
+ a = np.sort(gt_logits[i].cpu().numpy())[::-1]
227
+ print(a[:20])
228
+ print(a[-20:])
229
+
230
+ print("\n===== Random logits sanity check =====")
231
+ for k, r in zip(Ks, recall_rand):
232
+ print(f"Random Recall@{k}: {r:.4f}")
233
+ for k, n in zip(Ks, ndcg_rand):
234
+ print(f"Random NDCG@{k}: {n:.4f}")
235
+
236
+
237
+ if __name__ == "__main__":
238
+ main()