yuta24's picture
v0.1.0-alpha: PyTorch LoRA + heads, code, model card
e379257 verified
Raw History Blame Contribute Delete
10.3 kB
import math
import pytest
import torch
from glint.box import (BoxHead, BoxLossWeights, box_answer, box_iou, box_loss, box_metrics, decode_box, decode_edges,
dfl_loss, generalized_iou, max_prob_confidence, order_box, two_hot)
K = 64
def logits_from_target(target, bins=K):
return two_hot(target, bins).clamp_min(1e-12).log()
# ---------------------------------------------------------------- bins encode / decode
@pytest.mark.parametrize("bins", [2, 16, 64])
def test_two_hot_round_trip(bins):
torch.manual_seed(0)
t = torch.rand(100, 4)
t[0] = torch.tensor([0.0, 1.0, 0.0, 1.0]) # exact endpoints
t[1] = torch.tensor([0.5, 1 / (bins - 1), 1 - 1e-7, min(1.0, 2 / (bins - 1))])
th = two_hot(t, bins)
assert th.shape == (100, 4, bins)
assert torch.allclose(th.sum(-1), torch.ones(100, 4))
assert ((th > 0).sum(-1) <= 2).all() # mass only on two adjacent bins
assert torch.allclose(decode_edges(logits_from_target(t, bins)), t, atol=1e-5)
def test_two_hot_clamps_out_of_range():
t = torch.tensor([[-0.2, 1.3, 0.25, 0.75]])
assert torch.allclose(decode_edges(logits_from_target(t)), t.clamp(0, 1), atol=1e-5)
# ---------------------------------------------------------------- DFL
def test_dfl_matches_manual_and_is_minimal_at_target():
torch.manual_seed(0)
t = torch.tensor([[0.1, 0.4, 0.33, 0.9]])
z = torch.randn(1, 4, K, requires_grad=True)
loss = dfl_loss(z, t)
manual = 0.0
for e in range(4):
x = float(t[0, e]) * (K - 1); lo = int(math.floor(x)); w = x - lo
lp = z[0, e].log_softmax(-1)
manual += -((1 - w) * lp[lo] + w * lp[lo + 1])
assert torch.allclose(loss, manual / 4, atol=1e-5)
loss.backward()
assert torch.isfinite(z.grad).all() and z.grad.abs().sum() > 0
# loss at the two-hot target logits == entropy of the two-hot distribution (its minimum)
th = two_hot(t, K)
ent = -(th * th.clamp_min(1e-12).log()).sum(-1).mean()
assert torch.allclose(dfl_loss(logits_from_target(t), t), ent, atol=1e-4)
assert dfl_loss(logits_from_target(t), t) < dfl_loss(torch.zeros(1, 4, K), t)
def test_dfl_exact_bin_target_is_one_hot_ce():
t = torch.full((1, 4), 10 / (K - 1))
z = torch.randn(1, 4, K)
assert torch.allclose(dfl_loss(z, t), -z.log_softmax(-1)[..., 10].mean(), atol=1e-5)
# ---------------------------------------------------------------- IoU / GIoU
def test_iou_giou_known_values():
a = torch.tensor([0.0, 0.5, 0.0, 0.5]) # top, bottom, left, right
assert torch.allclose(box_iou(a, a), torch.tensor(1.0))
assert torch.allclose(generalized_iou(a, a), torch.tensor(1.0))
b = torch.tensor([0.25, 0.75, 0.25, 0.75]) # overlap 0.25x0.25
assert torch.allclose(box_iou(a, b), torch.tensor(0.0625 / (0.25 + 0.25 - 0.0625)), atol=1e-6)
c = torch.tensor([0.5, 1.0, 0.5, 1.0]) # touching corner, hull = 1
assert torch.allclose(box_iou(a, c), torch.tensor(0.0))
assert torch.allclose(generalized_iou(a, c), torch.tensor(-0.5), atol=1e-6)
d = torch.tensor([0.0, 0.1, 0.9, 1.0])
assert generalized_iou(a, d) < 0 and generalized_iou(a, d) > -1
def test_giou_gradient_for_disjoint_boxes():
p = torch.tensor([0.0, 0.1, 0.0, 0.1], requires_grad=True)
g = torch.tensor([0.8, 0.9, 0.8, 0.9])
(1 - generalized_iou(p, g)).backward()
assert p.grad.abs().sum() > 0 # IoU alone would have zero gradient here
def test_iou_broadcast():
a = torch.rand(5, 4).sort(-1).values[:, [0, 1, 2, 3]]
a = order_box(a)
assert box_iou(a, a).shape == (5,)
assert torch.allclose(box_iou(a, a), torch.ones(5), atol=1e-5)
# ---------------------------------------------------------------- ordering
def test_order_box_swaps_inverted_edges():
e = torch.tensor([[0.8, 0.2, 0.9, 0.1], [0.1, 0.2, 0.3, 0.4]])
assert torch.equal(order_box(e), torch.tensor([[0.2, 0.8, 0.1, 0.9], [0.1, 0.2, 0.3, 0.4]]))
z = logits_from_target(torch.tensor([[0.7, 0.3, 0.6, 0.2]]))
t, b, l, r = decode_box(z)[0].tolist()
assert t <= b and l <= r
def test_box_answer_jev_shape_and_strict_order():
a = box_answer(torch.tensor([0.7, 0.3, 0.5, 0.5]), 1.3)
assert a["type"] == "box" and set(a["box"]) == {"top", "bottom", "left", "right"}
assert a["box"]["top"] < a["box"]["bottom"] and a["box"]["left"] < a["box"]["right"]
assert a["box"]["top"] == 0.3 and a["box"]["bottom"] == 0.7
assert a["confidence"] == 1.0
z = box_answer([1.0, 1.0, 0.0, 0.0], 0.5)["box"] # degenerate at the image border
assert 0 <= z["top"] < z["bottom"] <= 1 and 0 <= z["left"] < z["right"] <= 1
# ---------------------------------------------------------------- loss / metrics
def test_box_loss_parts_and_backward():
torch.manual_seed(0)
z = torch.randn(3, 4, K, requires_grad=True); c = torch.zeros(3, requires_grad=True)
tgt = torch.tensor([[0.1, 0.2, 0.3, 0.6], [0.5, 0.9, 0.1, 0.2], [0.0, 1.0, 0.0, 1.0]])
loss, parts = box_loss(z, c, tgt, BoxLossWeights())
assert set(parts) == {"dfl", "l1", "giou", "conf", "iou"}
loss.backward()
assert torch.isfinite(z.grad).all() and z.grad.abs().sum() > 0 and c.grad.abs().sum() > 0
w0, _ = box_loss(z, c, tgt, BoxLossWeights(dfl=0, l1=0, giou=0, conf=0))
assert float(w0.detach()) == 0.0
perfect, pp = box_loss(logits_from_target(tgt), torch.full((3,), 10.0), tgt)
assert pp["l1"] < 1e-4 and pp["giou"] < 1e-3 and pp["iou"] > 0.999
def test_max_prob_confidence_range():
z = torch.randn(2, 4, K)
c = max_prob_confidence(z)
assert c.shape == (2,) and ((c > 0) & (c <= 1)).all()
assert torch.allclose(max_prob_confidence(logits_from_target(torch.rand(2, 4))), torch.ones(2), atol=1e-5)
def test_box_metrics():
m = box_metrics([0.9, 0.6, 0.3, 0.8], [0.9, 0.7, 0.2, 0.8], [0.5] * 4)
assert m["n_box_questions"] == 4
assert m["mean_iou"] == pytest.approx(0.65)
assert m["iou_ge_0.5"] == 0.75 and m["iou_ge_0.75"] == 0.5
assert 0 <= m["ece_15bin_conf_vs_iou50"] <= 1 and "ece_15bin_maxprob_vs_iou50" in m
assert box_metrics([], []) == {"n_box_questions": 0}
# ---------------------------------------------------------------- head forward / backward
class _Rec:
def __init__(self, spans, coords=None):
from glint.schema import EncodedBoxQuestion
self.box_questions = tuple(EncodedBoxQuestion(f"q{i}", s) for i, s in enumerate(spans))
self.image_coords = coords
def test_box_head_forward_backward_random_hidden():
torch.manual_seed(0)
H, L = 48, 40
head = BoxHead(hidden_size=H, width=32, layers=2, heads=4, feedforward=64, bins=K)
hidden = torch.randn(2, L, H, requires_grad=True)
mask = torch.ones(2, L, dtype=torch.long); mask[1, 30:] = 0
coords = (tuple(range(2, 14)), tuple(((r + .5) / 3, (c + .5) / 4) for r in range(3) for c in range(4)))
recs = [_Rec([(20, 25), (26, 30)], coords), _Rec([(5, 9)])]
out = head(hidden, mask, recs)
assert out[0].logits.shape == (2, 4, K) and out[0].conf_logit.shape == (2,)
assert out[1].logits.shape == (1, 4, K) and out[0].question_ids == ("q0", "q1")
tgt = torch.tensor([[0.1, 0.3, 0.2, 0.8], [0.6, 0.7, 0.1, 0.4]])
loss, _ = box_loss(out[0].logits, out[0].conf_logit, tgt)
loss2, _ = box_loss(out[1].logits, out[1].conf_logit, tgt[:1])
(loss + loss2).backward()
assert hidden.grad is not None and torch.isfinite(hidden.grad).all()
assert hidden.grad[1, 30:].abs().sum() == 0 # padding is ignored
for name, p in head.named_parameters():
assert p.grad is not None, name
assert head(hidden, mask, [_Rec([])])[0] is None
def test_box_head_questions_do_not_interact():
torch.manual_seed(0)
head = BoxHead(hidden_size=16, width=16, layers=1, heads=2, feedforward=32, bins=8).eval()
hidden = torch.randn(1, 20, 16); mask = torch.ones(1, 20, dtype=torch.long)
both = head(hidden, mask, [_Rec([(2, 5), (8, 12)])])[0]
one = head(hidden, mask, [_Rec([(2, 5)])])[0]
assert torch.allclose(both.logits[0], one.logits[0], atol=1e-5)
def test_box_head_can_overfit_and_bf16():
torch.manual_seed(0)
head = BoxHead(hidden_size=16, width=32, layers=1, heads=4, feedforward=64, bins=K)
hidden = torch.randn(1, 12, 16); mask = torch.ones(1, 12, dtype=torch.long)
rec = [_Rec([(3, 6)])]; tgt = torch.tensor([[0.42, 0.47, 0.18, 0.83]])
opt = torch.optim.AdamW(head.parameters(), lr=3e-3)
for _ in range(150):
o = head(hidden, mask, rec)[0]; loss, parts = box_loss(o.logits, o.conf_logit, tgt)
opt.zero_grad(); loss.backward(); opt.step()
assert parts["iou"] > 0.9
with torch.no_grad():
assert float(torch.sigmoid(head(hidden, mask, rec)[0].conf_logit)) > 0.7 # confidence tracks IoU
hb = head.to(torch.bfloat16)
ob = hb(hidden.bfloat16(), mask, rec)[0]
assert ob.logits.dtype == torch.bfloat16 and box_iou(decode_box(ob.logits)[0], tgt[0]) > 0.8
def test_box_head_edges_do_not_collapse():
# regression: with a tiny edge embedding all 4 edges decoded to the same value (the L1-optimal mean of the targets)
torch.manual_seed(1)
head = BoxHead(hidden_size=16, width=32, layers=1, heads=4, feedforward=64, bins=K)
hidden = torch.randn(4, 12, 16); mask = torch.ones(4, 12, dtype=torch.long)
tgt = torch.tensor([[0.48, 0.54, 0.50, 0.86], [0.53, 0.59, 0.08, 0.92], [0.20, 0.30, 0.10, 0.40], [0.70, 0.95, 0.60, 0.90]])
opt = torch.optim.AdamW(head.parameters(), lr=1e-3)
for step in range(400):
i = step % 4
o = head(hidden[i:i + 1], mask[i:i + 1], [_Rec([(3, 6)])])[0]
loss, _ = box_loss(o.logits, o.conf_logit, tgt[i:i + 1])
opt.zero_grad(); loss.backward(); opt.step()
with torch.no_grad():
out = torch.stack([decode_box(head(hidden[i:i + 1], mask[i:i + 1], [_Rec([(3, 6)])])[0].logits)[0] for i in range(4)])
assert box_iou(out, tgt).mean() > 0.5