shuishen
7 days ago faf5be476037d3baf4da3515789906a176f9b040
capabilities/05-3d-pointcloud/train_pointcloud_semantic_model.py
@@ -7,6 +7,7 @@
from __future__ import annotations
import argparse
import copy
import hashlib
import json
import time
@@ -97,6 +98,7 @@
    parser.add_argument("--device", choices={"auto", "cpu", "cuda"}, default="auto")
    parser.add_argument("--epochs", type=int, default=40)
    parser.add_argument("--batch-size", type=int, default=4096)
    parser.add_argument("--seed", type=int, default=42)
    args = parser.parse_args()
    if args.output.exists() and any(args.output.iterdir()):
        raise SystemExit("Output directory must be new or empty.")
@@ -135,15 +137,32 @@
    if args.device == "cuda" and not torch.cuda.is_available():
        raise SystemExit("CUDA was requested but is unavailable.")
    device = torch.device(device_name)
    torch.manual_seed(42)
    torch.manual_seed(args.seed)
    if device_name == "cuda":
        torch.cuda.manual_seed_all(args.seed)
        torch.backends.cudnn.benchmark = False
        torch.backends.cudnn.deterministic = True
    model = PointWiseNet(len(class_codes)).to(device)
    optimizer = torch.optim.AdamW(model.parameters(), lr=0.001, weight_decay=1e-4)
    weight = torch.tensor([len(train_idx) / max(1, sum(label_by_index[int(i)] == group for i in train_idx)) for group in range(len(class_codes))], dtype=torch.float32, device=device)
    criterion = torch.nn.CrossEntropyLoss(weight=weight)
    train_labels = np.asarray([label_by_index[int(index)] for index in train_idx], dtype=np.int64)
    validation_true = np.asarray([target[np.searchsorted(indices, index)] for index in validation_idx], dtype=np.int64)
    test_true = np.asarray([target[np.searchsorted(indices, index)] for index in test_idx], dtype=np.int64)
    def predict(indices_to_predict: np.ndarray) -> np.ndarray:
        model.eval(); parts: list[np.ndarray] = []
        with torch.no_grad():
            for start in range(0, len(indices_to_predict), args.batch_size):
                logits = model(torch.from_numpy(features[indices_to_predict[start:start + args.batch_size]]).to(device))
                parts.append(logits.argmax(dim=1).cpu().numpy())
        return np.concatenate(parts)
    started = time.perf_counter()
    for _ in range(args.epochs):
        order = np.random.default_rng(42).permutation(len(train_idx))
    best_epoch = 0
    best_validation_macro_f1 = -1.0
    best_state: dict[str, torch.Tensor] | None = None
    for epoch in range(1, args.epochs + 1):
        order = np.random.default_rng(args.seed).permutation(len(train_idx))
        model.train()
        for start in range(0, len(order), args.batch_size):
            subset = train_idx[order[start:start + args.batch_size]]
@@ -152,15 +171,15 @@
            optimizer.zero_grad(set_to_none=True)
            criterion(model(x), y).backward()
            optimizer.step()
    def predict(indices_to_predict: np.ndarray) -> np.ndarray:
        model.eval(); parts: list[np.ndarray] = []
        with torch.no_grad():
            for start in range(0, len(indices_to_predict), args.batch_size):
                logits = model(torch.from_numpy(features[indices_to_predict[start:start + args.batch_size]]).to(device))
                parts.append(logits.argmax(dim=1).cpu().numpy())
        return np.concatenate(parts)
    validation_true = np.asarray([target[np.searchsorted(indices, index)] for index in validation_idx], dtype=np.int64)
    test_true = np.asarray([target[np.searchsorted(indices, index)] for index in test_idx], dtype=np.int64)
        validation_metrics = metrics(validation_true, np.asarray([class_codes[value] for value in predict(validation_idx)], dtype=np.int64), class_codes)
        macro_f1 = float(validation_metrics["report"]["macro avg"]["f1-score"])
        if macro_f1 > best_validation_macro_f1:
            best_epoch = epoch
            best_validation_macro_f1 = macro_f1
            best_state = copy.deepcopy(model.state_dict())
    if best_state is None:
        raise RuntimeError("Training did not produce a validation checkpoint.")
    model.load_state_dict(best_state)
    validation_pred = np.asarray([class_codes[value] for value in predict(validation_idx)], dtype=np.int64)
    test_pred = np.asarray([class_codes[value] for value in predict(test_idx)], dtype=np.int64)
    all_pred = np.asarray([class_codes[value] for value in predict(np.arange(len(features), dtype=np.int64))], dtype=np.uint8)
@@ -177,9 +196,9 @@
        "label_counts": {str(code): count for code, count in per_class.items()},
        "split_counts": {"train": int(len(train_idx)), "validation": int(len(validation_idx)), "test": int(len(test_idx))},
        "validation": metrics(validation_true, validation_pred, class_codes), "test": metrics(test_true, test_pred, class_codes),
        "normalizer": normalizer, "epochs": args.epochs, "batch_size": args.batch_size,
        "normalizer": normalizer, "epochs": args.epochs, "best_epoch": best_epoch, "best_validation_macro_f1": best_validation_macro_f1, "batch_size": args.batch_size, "seed": args.seed,
        "elapsed_seconds": round(time.perf_counter() - started, 3),
        "limitations": ["Metrics cover only human-confirmed points in this annotation revision.", "Spatial blocks reduce leakage but one small source cannot establish field-wide generalization.", "Rule candidate colours were not used as labels or input features."],
        "limitations": ["Metrics cover only human-confirmed points in this annotation revision.", "The retained checkpoint is selected by validation macro F1; the test split remains separate from that selection.", "Spatial blocks reduce leakage but one small source cannot establish field-wide generalization.", "Rule candidate colours were not used as labels or input features."],
    }
    torch.save({"state_dict": model.cpu().state_dict(), "class_codes": class_codes, "normalizer": normalizer, "schema_version": 1}, args.output / "model.pt")
    (args.output / "metrics.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")