Align disease attribution batching with survival evaluator

This commit is contained in:
2026-06-27 16:01:35 +08:00
parent 517d573d3b
commit 980dd7c282

View File

@@ -196,48 +196,6 @@ def write_summary_csv(
return len(rows) return len(rows)
def concat_padded_tensor_batches(chunks: list[Dict[str, torch.Tensor]]) -> Dict[str, torch.Tensor]:
if not chunks:
raise ValueError("Cannot concatenate an empty chunk list")
fill_values = {
"event_seq": PAD_IDX,
"time_seq": 0.0,
"readout_mask": False,
"padding_mask": False,
"other_type": 0,
"other_value": 0.0,
"other_value_kind": 0,
"other_time": 0.0,
}
out: Dict[str, torch.Tensor] = {}
for key in chunks[0]:
tensors = [chunk[key] for chunk in chunks]
shapes = [tuple(t.shape) for t in tensors]
if len(set(shapes)) == 1:
out[key] = torch.cat(tensors, dim=0)
continue
if any(t.ndim == 0 for t in tensors):
raise ValueError(f"Cannot concatenate scalar tensor key={key!r} with mismatched shapes")
max_shape = list(shapes[0])
for shape in shapes[1:]:
if len(shape) != len(max_shape):
raise ValueError(f"Cannot concatenate key={key!r} with shapes {shapes}")
max_shape = [max(a, b) for a, b in zip(max_shape, shape)]
padded: list[torch.Tensor] = []
fill = fill_values.get(key, 0)
for tensor in tensors:
target_shape = [int(tensor.shape[0]), *max_shape[1:]]
padded_tensor = tensor.new_full(target_shape, fill)
slices = tuple(slice(0, int(size)) for size in tensor.shape)
padded_tensor[slices] = tensor
padded.append(padded_tensor)
out[key] = torch.cat(padded, dim=0)
return out
def build_disease_ablated_slice( def build_disease_ablated_slice(
batch: Dict[str, torch.Tensor], batch: Dict[str, torch.Tensor],
row_indices: torch.Tensor, row_indices: torch.Tensor,
@@ -469,6 +427,12 @@ def parse_args() -> argparse.Namespace:
default=1e-7, default=1e-7,
help="Small lower bound for ratio denominators.", help="Small lower bound for ratio denominators.",
) )
parser.add_argument(
"--shard_rows",
type=int,
default=200_000,
help="Approximate number of detailed rows to buffer before writing one .npz shard.",
)
return parser.parse_args() return parser.parse_args()
@@ -561,6 +525,8 @@ def main() -> None:
raise ValueError("attribution_batch_size must be positive") raise ValueError("attribution_batch_size must be positive")
if float(args.ratio_eps) <= 0: if float(args.ratio_eps) <= 0:
raise ValueError("--ratio_eps must be positive") raise ValueError("--ratio_eps must be positive")
if int(args.shard_rows) <= 0:
raise ValueError("--shard_rows must be positive")
num_workers = int(cfg_get(args, cfg, "num_workers", 4)) num_workers = int(cfg_get(args, cfg, "num_workers", 4))
loader = DataLoader( loader = DataLoader(
@@ -611,51 +577,27 @@ def main() -> None:
shards: list[dict[str, Any]] = [] shards: list[dict[str, Any]] = []
summary_accumulator: dict[tuple[Any, ...], dict[str, float]] = {} summary_accumulator: dict[tuple[Any, ...], dict[str, float]] = {}
row_base_cache: dict[int, dict[str, Any]] = {} row_base_cache: dict[int, dict[str, Any]] = {}
pending_batch_chunks: list[Dict[str, torch.Tensor]] = [] shard_frames: list[pd.DataFrame] = []
pending_meta_chunks: list[list[dict[str, Any]]] = [] shard_buffer_rows = 0
pending_orig_risk_chunks: list[torch.Tensor] = []
pending_orig_hazard_chunks: list[torch.Tensor] = []
pending_n = 0
def flush_pending() -> None: def flush_shard_buffer() -> None:
nonlocal written_rows, shard_index, pending_batch_chunks, pending_meta_chunks nonlocal written_rows, shard_index, shard_frames, shard_buffer_rows
nonlocal pending_orig_risk_chunks, pending_orig_hazard_chunks, pending_n if shard_buffer_rows == 0:
if pending_n == 0:
return return
table = pd.concat(shard_frames, ignore_index=True).reindex(columns=OUTPUT_COLUMNS)
shard_name = f"part-{shard_index:06d}.npz"
shard_path = output_dir / shard_name
shard_rows = write_compressed_npz_table(shard_path, table)
shards.append({"file": shard_name, "rows": int(shard_rows)})
shard_index += 1
written_rows += int(shard_rows)
shard_frames = []
shard_buffer_rows = 0
ablated_batch = concat_padded_tensor_batches(pending_batch_chunks) def write_result_rows(meta_rows: list[dict[str, Any]], value_block: np.ndarray) -> None:
meta_rows = [row for chunk in pending_meta_chunks for row in chunk] nonlocal shard_buffer_rows
orig_risk = torch.cat(pending_orig_risk_chunks, dim=0).to(device) if not meta_rows:
orig_hazard = torch.cat(pending_orig_hazard_chunks, dim=0).to(device) return
with torch.no_grad():
ablated_risk = death_risk_for_batch(
model=model,
batch=ablated_batch,
device=device,
model_target_mode=model_target_mode,
readout_name=readout_name,
readout_reduce=readout_reduce,
dist_mode=dist_mode,
tau=tau,
)
ablated_hazard = mortality_hazard_from_risk(ablated_risk)
attr_prob = orig_risk - ablated_risk
attr_hazard = orig_hazard - ablated_hazard
ratio_prob = safe_ratio(orig_risk, ablated_risk, eps=float(args.ratio_eps))
ratio_hazard = safe_ratio(orig_hazard, ablated_hazard, eps=float(args.ratio_eps))
value_block = torch.stack(
[
orig_risk,
orig_hazard,
ablated_risk,
ablated_hazard,
attr_prob,
attr_hazard,
ratio_prob,
ratio_hazard,
],
dim=1,
).detach().cpu().numpy()
for i, row in enumerate(meta_rows): for i, row in enumerate(meta_rows):
row["death_risk"] = float(value_block[i, 0]) row["death_risk"] = float(value_block[i, 0])
@@ -669,17 +611,10 @@ def main() -> None:
table = pd.DataFrame(meta_rows).reindex(columns=OUTPUT_COLUMNS) table = pd.DataFrame(meta_rows).reindex(columns=OUTPUT_COLUMNS)
update_summary_accumulator(summary_accumulator, table) update_summary_accumulator(summary_accumulator, table)
shard_name = f"part-{shard_index:06d}.npz" shard_frames.append(table)
shard_path = output_dir / shard_name shard_buffer_rows += len(table)
shard_rows = write_compressed_npz_table(shard_path, table) if shard_buffer_rows >= int(args.shard_rows):
shards.append({"file": shard_name, "rows": int(shard_rows)}) flush_shard_buffer()
shard_index += 1
written_rows += len(meta_rows)
pending_batch_chunks = []
pending_meta_chunks = []
pending_orig_risk_chunks = []
pending_orig_hazard_chunks = []
pending_n = 0
def get_row_base(row_idx: int) -> dict[str, Any]: def get_row_base(row_idx: int) -> dict[str, Any]:
cached = row_base_cache.get(row_idx) cached = row_base_cache.get(row_idx)
@@ -756,8 +691,7 @@ def main() -> None:
pair_offset = 0 pair_offset = 0
while pair_offset < int(pair_indices.shape[0]): while pair_offset < int(pair_indices.shape[0]):
capacity = int(attribution_batch_size) - pending_n pair_stop = min(int(pair_indices.shape[0]), pair_offset + int(attribution_batch_size))
pair_stop = min(int(pair_indices.shape[0]), pair_offset + capacity)
pair_chunk = pair_indices[pair_offset:pair_stop] pair_chunk = pair_indices[pair_offset:pair_stop]
local_rows = pair_chunk[:, 0].long() local_rows = pair_chunk[:, 0].long()
disease_token_ids = scanned_disease_tensor[pair_chunk[:, 1]].long() disease_token_ids = scanned_disease_tensor[pair_chunk[:, 1]].long()
@@ -766,6 +700,37 @@ def main() -> None:
row_indices=local_rows, row_indices=local_rows,
token_ids=disease_token_ids, token_ids=disease_token_ids,
) )
with torch.no_grad():
ablated_risk = death_risk_for_batch(
model=model,
batch=ablated_chunk,
device=device,
model_target_mode=model_target_mode,
readout_name=readout_name,
readout_reduce=readout_reduce,
dist_mode=dist_mode,
tau=tau,
)
orig_risk = death_risk_tensor[local_rows]
orig_hazard = death_hazard_tensor[local_rows]
ablated_hazard = mortality_hazard_from_risk(ablated_risk)
attr_prob = orig_risk - ablated_risk
attr_hazard = orig_hazard - ablated_hazard
ratio_prob = safe_ratio(orig_risk, ablated_risk, eps=float(args.ratio_eps))
ratio_hazard = safe_ratio(orig_hazard, ablated_hazard, eps=float(args.ratio_eps))
value_block = torch.stack(
[
orig_risk,
orig_hazard,
ablated_risk,
ablated_hazard,
attr_prob,
attr_hazard,
ratio_prob,
ratio_hazard,
],
dim=1,
).detach().cpu().numpy()
meta_chunk: list[dict[str, Any]] = [] meta_chunk: list[dict[str, Any]] = []
row_ids = batch_dev["row_idx"][local_rows].detach().cpu().numpy().astype(np.int64) row_ids = batch_dev["row_idx"][local_rows].detach().cpu().numpy().astype(np.int64)
@@ -810,18 +775,10 @@ def main() -> None:
} }
) )
if meta_chunk: write_result_rows(meta_chunk, value_block)
pending_batch_chunks.append(ablated_chunk)
pending_meta_chunks.append(meta_chunk)
pending_orig_risk_chunks.append(death_risk_tensor[local_rows].detach())
pending_orig_hazard_chunks.append(death_hazard_tensor[local_rows].detach())
pending_n += len(meta_chunk)
pair_offset = pair_stop pair_offset = pair_stop
if pending_n >= int(attribution_batch_size): flush_shard_buffer()
flush_pending()
flush_pending()
if not shards: if not shards:
empty_path = output_dir / "part-000000.npz" empty_path = output_dir / "part-000000.npz"
write_compressed_npz_table(empty_path, pd.DataFrame(columns=OUTPUT_COLUMNS)) write_compressed_npz_table(empty_path, pd.DataFrame(columns=OUTPUT_COLUMNS))