CAFF / scripts /probe_hc3_fix.py
MrDhifallah's picture
Add files using upload-large-folder tool
da28b2a verified
Raw
History Blame Contribute Delete
7.61 kB
"""
scripts/probe_hc3_fix.py - Test the proposed cross-query contrastive
fix WITHOUT modifying caff/miners.py or retraining.
Section 22 established that the existing HC3 loss is inert: positive
and negative share the same (query_id, hop) group, hence the same
teacher-forced z_prev, hence identical scores and zero gradient.
This probe simulates the proposed fix (drawing the negative from a
DIFFERENT query that shares the same (relation, hop), so its z_prev
differs) and measures whether that restores a non-zero contrast and
gradient. It tests BOTH scoring paths:
* score_candidates (post-sigmoid, what HC3 currently uses)
* score_logits (pre-sigmoid, the proposed improvement)
IMPORTANT (honesty note): this cross-query variant changes BOTH the
query embedding q AND the context z between positive and negative, so
it is NOT the paper's HC3 ("same triple, different context"). It is a
cross-query contrastive variant. We measure it because the empirical
effect on F1 is worth recording regardless of the name.
It only reads the model and data; it changes nothing on disk.
Run:
python scripts/probe_hc3_fix.py --checkpoint runs/caff_orphanet/seed_42/best.pt --device cuda
"""
from __future__ import annotations
import argparse
import statistics
import sys
from collections import defaultdict
from pathlib import Path
import torch
ROOT = Path(__file__).parent.parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from caff.data import CachedBFSExtractor, load_qa_split, CAFFTripleDataset # noqa
from caff.trainer import teacher_forced_z_prev
from evaluate import load_checkpoint
def build_per_query_by_hop(instances):
"""query_id -> {hop -> [instances]} so teacher_forced_z_prev can run."""
out = defaultdict(lambda: defaultdict(list))
for inst in instances:
out[inst.query_id][inst.hop].append(inst)
return out
def score(model, hop_idx, z, q_emb, relation, use_logits):
z = z.unsqueeze(0) # (1, d)
W_ctx = model.get_hop_W_ctx(hop_idx, z).squeeze(0)
E_r = model.relation_cache.get_batch([relation])
scorer = model.hop_scorers[hop_idx]
if use_logits:
s = scorer.score_logits(W_ctx, model.v, q_emb, E_r)
else:
s = scorer.score_candidates(W_ctx, model.v, q_emb, E_r)
return s.squeeze(0)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--checkpoint", default="runs/caff_orphanet/seed_42/best.pt")
ap.add_argument("--device", default="cuda")
ap.add_argument("--cache-dir", default="cache")
ap.add_argument("--num-pairs", type=int, default=30)
args = ap.parse_args()
device = args.device if torch.cuda.is_available() else "cpu"
print(f"Device: {device}")
model, config, ablation, encoder, kg = load_checkpoint(
args.checkpoint, device, Path(args.cache_dir))
model.train()
bfs = CachedBFSExtractor(kg, L=config.L, K_r=config.K_r,
cache_dir=Path(args.cache_dir) / "bfs")
recs = load_qa_split(config.train_path)
ds = CAFFTripleDataset(recs, bfs, require_gold=True)
per_query = build_per_query_by_hop(ds.instances)
# Query embedding cache (frozen encoder)
q_cache = {}
def get_q(qid, question):
if qid not in q_cache:
with torch.no_grad():
q_cache[qid] = encoder.encode([question])[0].to(device)
return q_cache[qid]
# Group positives and negatives by (relation, hop) ACROSS queries,
# restricted to hop >= 2 (where teacher-forced z is non-zero).
pos_by_relhop = defaultdict(list)
neg_by_relhop = defaultdict(list)
for inst in ds.instances:
if inst.hop < 2:
continue
key = (inst.relation, inst.hop)
(pos_by_relhop if inst.label == 1 else neg_by_relhop)[key].append(inst)
# Build cross-query (pos, neg) pairs: same (relation, hop), DIFFERENT query
pairs = []
for key, positives in pos_by_relhop.items():
negs = neg_by_relhop.get(key, [])
if not negs:
continue
for p in positives:
cross = [n for n in negs if n.query_id != p.query_id]
if cross:
pairs.append((p, cross[0]))
break # one pair per key is enough for the probe
if len(pairs) >= args.num_pairs:
break
print(f"Cross-query (pos, neg) pairs found (hop>=2): {len(pairs)}")
if not pairs:
print("No cross-query same-(relation,hop) pairs at hop>=2.")
print("The fix cannot create contrast on this data either.")
return 0
for use_logits in (False, True):
mode = "score_logits (pre-sigmoid)" if use_logits else "score_candidates (sigmoid)"
diffs = []
pos_list, neg_list = [], []
for p, n in pairs:
z_pos = teacher_forced_z_prev(model.csv, per_query[p.query_id],
target_hop=p.hop, d=config.d, device=device)
z_neg = teacher_forced_z_prev(model.csv, per_query[n.query_id],
target_hop=n.hop, d=config.d, device=device)
q_pos = get_q(p.query_id, p.question)
q_neg = get_q(n.query_id, n.question)
s_pos = score(model, p.hop - 1, z_pos, q_pos, p.relation, use_logits)
s_neg = score(model, n.hop - 1, z_neg, q_neg, n.relation, use_logits)
diffs.append((s_pos - s_neg).abs().item())
pos_list.append(s_pos)
neg_list.append(s_neg)
pos_t = torch.stack(pos_list)
neg_t = torch.stack(neg_list)
margin = config.gamma_C
l_hc3 = torch.relu(neg_t - pos_t + margin).mean()
# also measure z difference to confirm contexts really differ
z_diffs = []
for p, n in pairs:
z_pos = teacher_forced_z_prev(model.csv, per_query[p.query_id],
target_hop=p.hop, d=config.d, device=device)
z_neg = teacher_forced_z_prev(model.csv, per_query[n.query_id],
target_hop=n.hop, d=config.d, device=device)
z_diffs.append((z_pos - z_neg).abs().max().item())
print("\n" + "=" * 70)
print(f"MODE: {mode}")
print("=" * 70)
print(f" pairs : {len(diffs)}")
print(f" mean |z_pos - z_neg|max : {statistics.mean(z_diffs):.4f}")
print(f" mean |s_pos - s_neg| : {statistics.mean(diffs):.4e}")
print(f" max |s_pos - s_neg| : {max(diffs):.4e}")
print(f" contrastive loss value : {l_hc3.item():.6f} (margin={margin})")
model.zero_grad()
if l_hc3.requires_grad:
l_hc3.backward()
total = sum(p.grad.abs().sum().item()
for _, p in model.named_parameters() if p.grad is not None)
nz = sum(1 for _, p in model.named_parameters()
if p.grad is not None and p.grad.abs().sum().item() > 0)
print(f" total |grad| sum : {total:.6e}")
print(f" params with grad>0 : {nz}")
print("\n" + "=" * 70)
print("INTERPRETATION")
print("=" * 70)
print("If |s_pos - s_neg| > 0 and total |grad| > 0 (especially in the")
print("score_logits mode), the cross-query contrastive variant produces")
print("a real training signal, unlike the inert same-group HC3. If the")
print("sigmoid mode is ~0 but logits mode is > 0, saturation was masking")
print("the signal and the fix must operate on logits.")
print()
return 0
if __name__ == "__main__":
sys.exit(main())