TL;DR

We built a neural network that predicts full drag coefficient curves (41 Mach points from 0.5 to 4.5) for rifle bullets using only basic specifications like weight, caliber, and ballistic coefficient. The system achieves 3.15% mean absolute error and has been serving predictions in production since September 2025. This post walks through the technical implementation details, architecture decisions, and lessons learned building a real-world ML system for ballistic physics.

Read the full whitepaper: Transfer Learning for Predictive Custom Drag Modeling (17 pages)


The Problem: Drag Curves Are Scarce, But Critical

If you've ever built a ballistic calculator, you know the challenge: accurate drag modeling is everything. Standard drag models (G1, G7, G8) work okay for "average" bullets, but modern precision shooting demands better. Custom Drag Models (CDMs) — full drag coefficient curves measured with doppler radar — are the gold standard. They capture the unique aerodynamic signature of each bullet design.

The catch? Getting a CDM requires: - Access to a doppler radar range (≈$500K+ equipment) - Firing 50-100 rounds at various velocities - Expert analysis to process the raw data - Cost: $5,000-$15,000 per bullet

For manufacturers like Hornady and Lapua, this is routine. For smaller manufacturers or custom bullet makers? Not happening. We had 641 bullets with real radar-measured CDMs and thousands of bullets with only basic specs. Could we use machine learning to bridge the gap?


The Vision: Transfer Learning from Radar Data

The core insight: bullets with similar physical characteristics have similar drag curves. A 168gr .308 boattail match bullet from Manufacturer A will drag similarly to one from Manufacturer B. We could train a neural network on our 641 radar-measured bullets and use transfer learning to predict CDMs for bullets we've never measured.

But we faced an immediate data problem: 641 samples isn't much for deep learning. Enter synthetic data augmentation.

Part 1: Automating Data Extraction with Claude Vision

Applied Ballistics publishes ballistic data for 704+ bullets as JPEG images. Manual data entry would take 1,408 hours (704 bullets × 2 hours each). We needed automation.

The Vision Processing Pipeline

We built an extraction pipeline using Claude 3.5 Sonnet's vision capabilities:

import anthropic
import base64
from pathlib import Path

def extract_bullet_data(image_path: str) -> dict:
    """Extract bullet specifications from AB datasheet JPEG."""
    client = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])

    # Load and encode image
    with open(image_path, "rb") as f:
        image_data = base64.standard_b64encode(f.read()).decode("utf-8")

    # Vision extraction prompt
    message = client.messages.create(
        model="claude-3-5-sonnet-20241022",
        max_tokens=1024,
        messages=[{
            "role": "user",
            "content": [
                {
                    "type": "image",
                    "source": {
                        "type": "base64",
                        "media_type": "image/jpeg",
                        "data": image_data,
                    },
                },
                {
                    "type": "text",
                    "text": """Extract the following from this Applied Ballistics bullet datasheet:
                    - Caliber (inches, decimal format)
                    - Bullet weight (grains)
                    - G1 Ballistic Coefficient
                    - G7 Ballistic Coefficient
                    - Bullet length (inches, if visible)
                    - Ogive radius (calibers, if visible)

                    Return as JSON with keys: caliber, weight_gr, bc_g1, bc_g7, length_in, ogive_radius_cal"""
                }
            ],
        }]
    )

    # Parse response
    data = json.loads(message.content[0].text)

    # Physics validation
    validate_bullet_physics(data)

    return data

def validate_bullet_physics(data: dict):
    """Sanity checks for extracted data."""
    caliber = data['caliber']
    weight = data['weight_gr']

    # Caliber bounds
    assert 0.172 <= caliber <= 0.50, f"Invalid caliber: {caliber}"

    # Weight-to-caliber ratio (sectional density proxy)
    ratio = weight / (caliber  3)
    assert 0.5 <= ratio <= 2.0, f"Implausible weight for caliber: {weight}gr @ {caliber}in"

    # BC sanity
    assert 0.1 <= data['bc_g1'] <= 1.2, f"Invalid G1 BC: {data['bc_g1']}"
    assert 0.1 <= data['bc_g7'] <= 0.9, f"Invalid G7 BC: {data['bc_g7']}"

Vision Processing Pipeline

Figure 2: Claude Vision extraction pipeline - from JPEG datasheets to structured bullet specifications

Results: - 704/704 successful extractions (100% success rate) - 2.3 seconds per bullet (average) - 27 minutes total vs. 1,408 hours manual - 99.97% time savings

We validated against a manually-verified subset of 50 bullets: - 100% match on caliber - 98% match on weight (±0.5 grain tolerance) - 96% match on BC values (±0.002 tolerance)

The vision model occasionally struggled with hand-drawn or low-quality scans, but the physics validation caught these errors before they corrupted our dataset.


Part 2: Generating Synthetic CDM Curves

Now we had 704 bullets with BC values but no full CDM curves. We needed to synthesize them.

The BC-to-CDM Transformation Algorithm

The relationship between ballistic coefficient and drag coefficient is straightforward:

BC = m / (C_d × d²)

Rearranging:
C_d(M) = m / (BC(M) × d²)

But BC values are typically single scalars, not curves. We developed a 5-step hybrid algorithm combining standard drag model references with BC-derived corrections:

Step 1: Base Reference Curve

Start with the G7 standard drag curve as a baseline (better for modern boattail bullets than G1):

def get_g7_reference_curve(mach_points: np.ndarray) -> np.ndarray:
    """G7 standard drag curve from McCoy (1999)."""
    # Precomputed G7 curve at 41 Mach points
    return interpolate_standard_curve("G7", mach_points)
Step 2: BC-Based Scaling

Scale the reference curve using extracted BC values:

def scale_by_bc(cd_base: np.ndarray, bc_actual: float, bc_reference: float = 0.221) -> np.ndarray:
    """Scale drag curve to match actual BC.

    BC_G7_ref = 0.221 (G7 standard projectile)
    """
    scaling_factor = bc_reference / bc_actual
    return cd_base * scaling_factor
Step 3: Multi-Regime Interpolation

When both G1 and G7 BCs are available, blend them based on Mach regime:

def blend_drag_models(mach: np.ndarray, cd_g1: np.ndarray, cd_g7: np.ndarray) -> np.ndarray:
    """Blend G1 and G7 curves based on flight regime.

    - Supersonic (M > 1.2): Use G1 (better for shock wave region)
    - Transonic (0.8 < M < 1.2): Cubic spline interpolation
    - Subsonic (M < 0.8): Use G7 (better for low-speed)
    """
    cd_blended = np.zeros_like(mach)

    for i, M in enumerate(mach):
        if M > 1.2:
            # Supersonic: G1 better captures shock effects
            cd_blended[i] = cd_g1[i]
        elif M < 0.8:
            # Subsonic: G7 better for boattail bullets
            cd_blended[i] = cd_g7[i]
        else:
            # Transonic: smooth interpolation
            t = (M - 0.8) / 0.4  # Normalize to [0, 1]
            cd_blended[i] = cubic_interpolate(cd_g7[i], cd_g1[i], t)

    return cd_blended
Step 4: Transonic Peak Generation

Model the transonic drag spike using a Gaussian kernel:

def add_transonic_peak(cd_base: np.ndarray, mach: np.ndarray,
                       bc_g1: float, bc_g7: float) -> np.ndarray:
    """Add realistic transonic drag spike.

    Peak amplitude calibrated from BC ratio (G1 worse than G7 in transonic).
    """
    # Estimate peak amplitude from BC discrepancy
    bc_ratio = bc_g1 / bc_g7
    peak_amplitude = 0.15 * (bc_ratio - 1.0)  # Empirically tuned

    # Gaussian centered at critical Mach
    M_crit = 1.0
    sigma = 0.15

    transonic_spike = peak_amplitude * np.exp(-((mach - M_crit)  2) / (2 * sigma  2))

    return cd_base + transonic_spike
Step 5: Monotonicity Enforcement

Apply Savitzky-Golay smoothing to prevent unphysical oscillations:

from scipy.signal import savgol_filter

def enforce_smoothness(cd_curve: np.ndarray, window_length: int = 7, polyorder: int = 3) -> np.ndarray:
    """Smooth drag curve while preserving transonic peak.

    Savitzky-Golay filter preserves peak shape better than moving average.
    """
    # Must have odd window length
    if window_length % 2 == 0:
        window_length += 1

    return savgol_filter(cd_curve, window_length, polyorder, mode='nearest')

Validation Against Ground Truth

We validated synthetic curves against 127 bullets where both BC values and full CDM curves were available:

Metric Value Notes
Mean Absolute Error 3.2% Across all Mach points
Transonic Error 4.8% Mach 0.8-1.2 (most challenging)
Supersonic Error 2.1% Mach 1.5-3.0 (best performance)
Shape Correlation r = 0.984 Pearson correlation

The synthetic curves satisfied all physics constraints: - Monotonic decrease in supersonic regime - Realistic transonic peaks (1.3-2.0× baseline) - Smooth transitions between regimes

Physics Validation

Figure 3: Validation of synthetic CDM curves against ground truth radar measurements

Total training data: 1,345 bullets (704 synthetic + 641 real) — 2.1× data augmentation.


Part 3: Architecture Exploration

With data ready, we explored four neural architectures:

1. Multi-Layer Perceptron (Baseline)

Simple feedforward network:

import torch
import torch.nn as nn

class CDMPredictor(nn.Module):
    """MLP for CDM prediction: 13 features → 41 Cd values."""

    def __init__(self, dropout: float = 0.2):
        super().__init__()
        self.network = nn.Sequential(
            nn.Linear(13, 256),
            nn.ReLU(),
            nn.Dropout(dropout),

            nn.Linear(256, 512),
            nn.ReLU(),
            nn.Dropout(dropout),

            nn.Linear(512, 512),
            nn.ReLU(),
            nn.Dropout(dropout),

            nn.Linear(512, 256),
            nn.ReLU(),
            nn.Dropout(dropout),

            nn.Linear(256, 41)  # Output: 41 Mach points
        )

    def forward(self, x):
        return self.network(x)

Input Features (13 total):

features = [
    'caliber',           # inches
    'weight_gr',         # grains
    'bc_g1',            # G1 ballistic coefficient
    'bc_g7',            # G7 ballistic coefficient
    'length_in',        # bullet length (imputed if missing)
    'ogive_radius_cal', # ogive radius in calibers
    'meplat_diam_in',   # meplat diameter
    'boat_tail_angle',  # boattail angle (degrees)
    'bearing_length',   # bearing surface length
    'sectional_density', # weight / caliber²
    'form_factor_g1',   # i / BC_G1
    'form_factor_g7',   # i / BC_G7
    'length_to_diameter' # L/D ratio
]

Network Architecture

Figure 4: MLP architecture - 13 input features through 4 hidden layers to 41 output Mach points

2. Physics-Informed Neural Network (PINN)

Added physics loss term enforcing drag model constraints:

class PINN_CDMPredictor(nn.Module):
    """Physics-Informed NN with drag equation constraints."""

    def __init__(self):
        super().__init__()
        # Same architecture as MLP
        self.network = build_mlp_network()

    def physics_loss(self, cd_pred: torch.Tensor, features: torch.Tensor, mach: torch.Tensor) -> torch.Tensor:
        """Enforce physics constraints on predictions.

        Constraints:
        1. Drag increases with Mach in subsonic
        2. Transonic peak exists near M=1
        3. Monotonic decrease in supersonic
        """
        # Constraint 1: Subsonic gradient
        subsonic_mask = mach < 0.8
        subsonic_cd = cd_pred[subsonic_mask]
        subsonic_grad = torch.diff(subsonic_cd)
        subsonic_violation = torch.relu(-subsonic_grad).sum()  # Penalize decreases

        # Constraint 2: Transonic peak
        transonic_mask = (mach >= 0.8) & (mach <= 1.2)
        transonic_cd = cd_pred[transonic_mask]
        peak_violation = torch.relu(1.1 - transonic_cd.max()).sum()  # Must exceed 1.1

        # Constraint 3: Supersonic monotonicity
        supersonic_mask = mach > 1.5
        supersonic_cd = cd_pred[supersonic_mask]
        supersonic_grad = torch.diff(supersonic_cd)
        supersonic_violation = torch.relu(supersonic_grad).sum()  # Penalize increases

        return subsonic_violation + peak_violation + supersonic_violation

def total_loss(cd_pred, cd_true, features, mach, lambda_physics=0.1):
    """Combined data + physics loss."""
    data_loss = nn.MSELoss()(cd_pred, cd_true)
    physics_loss = model.physics_loss(cd_pred, features, mach)

    return data_loss + lambda_physics * physics_loss

Result: Over-regularization. Physics loss was too strict, preventing the model from learning subtle variations. Performance degraded to 4.86% MAE.

3. Transformer Architecture

Treated the 41 Mach points as a sequence:

class TransformerCDM(nn.Module):
    """Transformer encoder for sequence-to-sequence CDM prediction."""

    def __init__(self, d_model=128, nhead=8, num_layers=4):
        super().__init__()

        self.feature_embedding = nn.Linear(13, d_model)

        encoder_layer = nn.TransformerEncoderLayer(
            d_model=d_model,
            nhead=nhead,
            dim_feedforward=512,
            dropout=0.1
        )
        self.transformer = nn.TransformerEncoder(encoder_layer, num_layers=num_layers)

        self.output_head = nn.Linear(d_model, 41)

    def forward(self, x):
        # x: [batch, 13]
        embedded = self.feature_embedding(x)  # [batch, d_model]
        embedded = embedded.unsqueeze(1).expand(-1, 41, -1)  # [batch, 41, d_model]

        transformed = self.transformer(embedded)  # [batch, 41, d_model]

        cd_pred = self.output_head(transformed[:, :, :]).squeeze(-1)  # [batch, 41]

        return cd_pred

Result: Mismatch between architecture and problem. CDM prediction isn't a sequence modeling task — Mach points are independent given bullet features. Performance: 6.05% MAE.

4. Neural ODE

Attempted to model drag as a continuous ODE:

from torchdiffeq import odeint

class DragODE(nn.Module):
    """Neural ODE for continuous drag modeling."""

    def __init__(self, hidden_dim=64):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(1 + 13, hidden_dim),  # Mach + features
            nn.Tanh(),
            nn.Linear(hidden_dim, hidden_dim),
            nn.Tanh(),
            nn.Linear(hidden_dim, 1)  # dCd/dM
        )

    def forward(self, t, state):
        # t: current Mach number
        # state: [Cd, features...]
        return self.net(torch.cat([t, state], dim=-1))

def predict_cdm(features, mach_points):
    """Integrate ODE to get Cd curve."""
    initial_cd = torch.tensor([0.5])  # Initial guess
    state = torch.cat([initial_cd, features])

    solution = odeint(ode_func, state, mach_points)

    return solution[:, 0]  # Extract Cd values

Result: Failed to converge due to dimension mismatch errors and extreme sensitivity to initial conditions. Abandoned after 2 days of debugging.

Architecture Comparison Results

Architecture MAE Smoothness Shape Correlation Status
MLP Baseline 3.66% 90.05% 0.9380 ✅ Best
Physics-Informed NN 4.86% 64.02% 0.8234 ❌ Over-regularized
Transformer 6.05% 56.83% 0.7891 ❌ Poor fit
Neural ODE --- --- --- ❌ Failed to converge

Architecture Comparison

Figure 5: Performance comparison across four neural architectures - MLP baseline wins

Key Insight: Simple MLP with dropout outperformed complex physics-constrained models. The training data already contained sufficient physics signal — explicit constraints hurt generalization.


Part 4: Production System Design

The POC model (3.66% MAE) validated the approach. Now we needed production hardening.

Training Pipeline Improvements

import pytorch_lightning as pl
from torch.utils.data import DataLoader, TensorDataset

class ProductionCDMModel(pl.LightningModule):
    """Production-ready CDM predictor with monitoring."""

    def __init__(self, learning_rate=1e-3, weight_decay=1e-4):
        super().__init__()
        self.save_hyperparameters()

        self.model = CDMPredictor(dropout=0.2)
        self.learning_rate = learning_rate
        self.weight_decay = weight_decay

        # Metrics tracking
        self.train_mae = []
        self.val_mae = []

    def forward(self, x):
        return self.model(x)

    def training_step(self, batch, batch_idx):
        features, cd_true = batch
        cd_pred = self(features)

        # Weighted MSE loss (emphasize transonic region)
        weights = self._get_mach_weights()
        loss = (weights * (cd_pred - cd_true)  2).mean()

        # Metrics
        mae = torch.abs(cd_pred - cd_true).mean()
        self.log('train_loss', loss)
        self.log('train_mae', mae)

        return loss

    def validation_step(self, batch, batch_idx):
        features, cd_true = batch
        cd_pred = self(features)

        loss = nn.MSELoss()(cd_pred, cd_true)
        mae = torch.abs(cd_pred - cd_true).mean()

        self.log('val_loss', loss)
        self.log('val_mae', mae)

        # Physics validation
        smoothness = self._calculate_smoothness(cd_pred)
        transonic_quality = self._check_transonic_peak(cd_pred)

        self.log('smoothness', smoothness)
        self.log('transonic_quality', transonic_quality)

        return loss

    def configure_optimizers(self):
        optimizer = torch.optim.AdamW(
            self.parameters(),
            lr=self.learning_rate,
            weight_decay=self.weight_decay
        )

        scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(
            optimizer,
            mode='min',
            factor=0.5,
            patience=5,
            verbose=True
        )

        return {
            'optimizer': optimizer,
            'lr_scheduler': scheduler,
            'monitor': 'val_loss'
        }

    def _get_mach_weights(self):
        """Weight transonic region more heavily."""
        weights = torch.ones(41)
        transonic_indices = (self.mach_points >= 0.8) & (self.mach_points <= 1.2)
        weights[transonic_indices] = 2.0  # 2x weight in transonic
        return weights / weights.sum()

    def _calculate_smoothness(self, cd_pred):
        """Measure curve smoothness (low = better)."""
        second_derivative = torch.diff(cd_pred, n=2, dim=-1)
        return 1.0 / (1.0 + second_derivative.abs().mean())

    def _check_transonic_peak(self, cd_pred):
        """Verify transonic peak exists and is realistic."""
        transonic_mask = (self.mach_points >= 0.8) & (self.mach_points <= 1.2)
        peak_cd = cd_pred[:, transonic_mask].max(dim=1)[0]
        baseline_cd = cd_pred[:, 0]  # Subsonic baseline

        return (peak_cd / baseline_cd).mean()  # Should be > 1.0

Training Configuration

# Data preparation
X_train, X_val, X_test = prepare_features()  # 1,039 → 831 / 104 / 104
y_train, y_val, y_test = prepare_targets()

train_dataset = TensorDataset(X_train, y_train)
val_dataset = TensorDataset(X_val, y_val)

train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=4)
val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False, num_workers=4)

# Model training
model = ProductionCDMModel(learning_rate=1e-3, weight_decay=1e-4)

trainer = pl.Trainer(
    max_epochs=100,
    callbacks=[
        pl.callbacks.EarlyStopping(monitor='val_loss', patience=10, mode='min'),
        pl.callbacks.ModelCheckpoint(monitor='val_mae', mode='min', save_top_k=3),
        pl.callbacks.LearningRateMonitor(logging_interval='epoch')
    ],
    accelerator='gpu',
    devices=1,
    log_every_n_steps=10
)

trainer.fit(model, train_loader, val_loader)

Training Convergence

Figure 6: Training and validation loss convergence over 60 epochs

Training Results: - Converged at epoch 60 (early stopping) - Final validation loss: 0.0023 - Production model MAE: 3.15% (13.9% improvement over POC) - Smoothness: 88.81% (close to ground truth 89.6%) - Shape correlation: 0.9545

CDM PredictionsFigure 7: Example predicted CDM curves compared to ground truth measurements

API Integration

# ballistics/ml/cdm_transfer_learning.py

import torch
import pickle
from pathlib import Path

class CDMTransferLearning:
    """Production CDM prediction service."""

    def __init__(self, model_path: str = "models/cdm_transfer_learning/production_mlp.pkl"):
        self.model = self._load_model(model_path)
        self.model.eval()

        # Feature statistics for normalization
        with open(model_path.replace('.pkl', '_stats.pkl'), 'rb') as f:
            self.feature_stats = pickle.load(f)

    def predict(self, bullet_data: dict) -> dict:
        """Predict CDM curve from bullet specifications.

        Args:
            bullet_data: Dict with keys: caliber, weight_gr, bc_g1, bc_g7, etc.

        Returns:
            Dict with mach_numbers, drag_coefficients, validation_metrics
        """
        # Feature engineering
        features = self._extract_features(bullet_data)
        features_normalized = self._normalize_features(features)

        # Prediction
        with torch.no_grad():
            cd_pred = self.model(torch.tensor(features_normalized, dtype=torch.float32))

        # Denormalize
        cd_values = cd_pred.numpy()

        # Validation
        validation = self._validate_prediction(cd_values)

        return {
            'mach_numbers': self.mach_points.tolist(),
            'drag_coefficients': cd_values.tolist(),
            'source': 'ml_transfer_learning',
            'method': 'mlp_prediction',
            'validation': validation
        }

    def _validate_prediction(self, cd_values: np.ndarray) -> dict:
        """Physics validation of predicted curve."""
        return {
            'smoothness': self._calculate_smoothness(cd_values),
            'transonic_quality': self._check_transonic_peak(cd_values),
            'negative_cd_count': (cd_values < 0).sum(),
            'physical_plausibility': self._check_plausibility(cd_values)
        }

REST API Endpoint

# routes/bullets_unified.py

@bp.route('/search', methods=['GET'])
def search_bullets():
    """Search unified bullet database with optional CDM prediction."""
    query = request.args.get('q', '')
    use_cdm_prediction = request.args.get('use_cdm_prediction', 'true').lower() == 'true'

    # Search database
    results = search_database(query)

    cdm_predictions_made = 0

    if use_cdm_prediction:
        cdm_predictor = CDMTransferLearning()

        for bullet in results:
            if bullet.get('cdm_data') is None:
                # Predict CDM if not available
                try:
                    cdm_data = cdm_predictor.predict({
                        'caliber': bullet['caliber'],
                        'weight_gr': bullet['weight_gr'],
                        'bc_g1': bullet.get('bc_g1'),
                        'bc_g7': bullet.get('bc_g7'),
                        'length_in': bullet.get('length_in'),
                        'ogive_radius_cal': bullet.get('ogive_radius_cal')
                    })

                    bullet['cdm_data'] = cdm_data
                    bullet['cdm_predicted'] = True
                    cdm_predictions_made += 1

                except Exception as e:
                    logger.warning(f"CDM prediction failed for bullet {bullet['id']}: {e}")

    return jsonify({
        'results': results,
        'cdm_prediction_enabled': use_cdm_prediction,
        'cdm_predictions_made': cdm_predictions_made
    })

Example Response:

{
  "results": [
    {
      "id": 1234,
      "manufacturer": "Sierra",
      "model": "MatchKing",
      "caliber": 0.308,
      "weight_gr": 168,
      "bc_g1": 0.462,
      "bc_g7": 0.237,
      "cdm_data": {
        "mach_numbers": [0.5, 0.55, 0.6, ..., 4.5],
        "drag_coefficients": [0.287, 0.289, 0.295, ..., 0.312],
        "source": "ml_transfer_learning",
        "method": "mlp_prediction",
        "validation": {
          "smoothness": 91.2,
          "transonic_quality": 1.45,
          "negative_cd_count": 0,
          "physical_plausibility": true
        }
      },
      "cdm_predicted": true
    }
  ],
  "cdm_prediction_enabled": true,
  "cdm_predictions_made": 18
}

Part 5: Deployment and Monitoring

Model Serving Architecture

┌─────────────────┐
│   Client App    │
└────────┬────────┘
         │
         ▼
┌─────────────────────────┐
│  Google Cloud Function  │
│  (Python 3.12)          │
│  - Flask routing        │
│  - Request validation   │
│  - Response formatting  │
└────────┬────────────────┘
         │
         ▼
┌─────────────────────────┐
│  CDMTransferLearning    │
│  - PyTorch model (2.1MB)│
│  - CPU inference (<10ms)│
│  - Feature engineering  │
└────────┬────────────────┘
         │
         ▼
┌─────────────────────────┐
│  Physics Validation     │
│  - Smoothness check     │
│  - Peak detection       │
│  - Plausibility gates   │
└─────────────────────────┘

Performance Characteristics

Model Size: - PyTorch state dict: 2.1 MB - TorchScript (optional): 2.3 MB - ONNX (optional): 1.8 MB

Inference Speed (CPU): - Single prediction: 6-8 ms - Batch of 10: 12-15 ms (1.2-1.5 ms per bullet) - Batch of 100: 80-100 ms (0.8-1.0 ms per bullet)

Cold Start: - Model load time: 150-200 ms - First prediction: 220-280 ms (including load) - Subsequent predictions: 6-8 ms

Memory Footprint: - Model in memory: ~15 MB - Peak during inference: ~30 MB

Production PerformanceFigure 8: Production inference performance metrics across different batch sizes

Monitoring and Observability

import newrelic.agent

class MonitoredCDMPredictor:
    """CDM predictor with New Relic monitoring."""

    def __init__(self):
        self.predictor = CDMTransferLearning()
        self.prediction_count = 0
        self.error_count = 0

    @newrelic.agent.function_trace()
    def predict(self, bullet_data: dict) -> dict:
        """Predict with telemetry."""
        self.prediction_count += 1

        try:
            # Track prediction time
            with newrelic.agent.FunctionTrace(name='cdm_prediction'):
                result = self.predictor.predict(bullet_data)

            # Custom metrics
            newrelic.agent.record_custom_metric('CDM/Predictions/Total', self.prediction_count)
            newrelic.agent.record_custom_metric('CDM/Validation/Smoothness',
                                               result['validation']['smoothness'])
            newrelic.agent.record_custom_metric('CDM/Validation/TransonicQuality',
                                               result['validation']['transonic_quality'])

            # Track feature availability
            features_available = sum(1 for k, v in bullet_data.items() if v is not None)
            newrelic.agent.record_custom_metric('CDM/Features/Available', features_available)

            return result

        except Exception as e:
            self.error_count += 1
            newrelic.agent.record_custom_metric('CDM/Errors/Total', self.error_count)
            newrelic.agent.notice_error()
            raise

Key Metrics Tracked: - Prediction latency (p50, p95, p99) - Validation scores (smoothness, transonic quality) - Feature availability (how many inputs provided) - Error rate and types - Cache hit rate (if caching enabled)


Lessons Learned

1. Simple Architectures Often Win

We spent a week exploring Transformers and Neural ODEs, only to find the vanilla MLP performed best. Why?

  • Data alignment: Our problem is function approximation, not sequence modeling
  • Inductive bias mismatch: Transformers expect temporal dependencies; drag curves don't have them
  • Regularization sufficiency: Dropout + weight decay provided enough regularization without physics constraints

Lesson: Start simple. Add complexity only when data clearly demands it.

2. Physics Validation > Physics Loss

Hard-coded physics loss functions became a liability: - Over-constrained the model - Required manual tuning of loss weights - Didn't generalize to all bullet types

Better approach: Validate predictions post-hoc and flag anomalies. Let the model learn physics from data.

3. Synthetic Data Quality Matters More Than Quantity

We generated 704 synthetic CDMs, but spent equal time validating them. Key insight: One bad synthetic sample can poison dozens of real samples during training.

Validation process: 1. Compare synthetic vs. real CDMs (where both exist) 2. Physics plausibility checks 3. Cross-validation with different BC values 4. Manual inspection of outliers

4. Feature Engineering > Model Complexity

The most impactful changes weren't architectural: - Adding sectional_density as a feature: -0.8% MAE - Computing form_factor_g1 and form_factor_g7: -0.6% MAE - Imputing missing features (length, ogive) using physics-based defaults: -0.5% MAE

Feature Importance

Figure 9: Feature importance analysis showing impact of each input feature on prediction accuracy

Combined improvement: -1.9% MAE with zero code changes to the model.

5. Production Deployment ≠ POC

Our POC model worked great in notebooks. Production required: - Input validation and sanitization - Graceful degradation when features missing - Physics validation gates - Monitoring and alerting - Model versioning and rollback capability - A/B testing infrastructure

Time split: 30% research, 70% production engineering.


What's Next?

Phase 2: Uncertainty Quantification

Current model outputs point estimates. We're implementing Bayesian Neural Networks to provide confidence intervals:

class BayesianCDMPredictor(nn.Module):
    """Bayesian NN with dropout as approximate inference."""

    def predict_with_uncertainty(self, features, n_samples=100):
        """Monte Carlo dropout for uncertainty estimation."""
        self.train()  # Enable dropout during inference

        predictions = []
        for _ in range(n_samples):
            with torch.no_grad():
                pred = self(features)
                predictions.append(pred)

        predictions = torch.stack(predictions)

        mean = predictions.mean(dim=0)
        std = predictions.std(dim=0)

        return {
            'cd_mean': mean,
            'cd_std': std,
            'cd_lower': mean - 1.96 * std,  # 95% CI
            'cd_upper': mean + 1.96 * std
        }

Use case: Flag predictions with high uncertainty for manual review or experimental validation.

Conclusion

Building a production ML system for ballistic drag prediction required more than just training a model: - Data engineering (Claude Vision automation saved countless hours) - Synthetic data generation (2.1× data augmentation) - Architecture exploration (simple MLP won) - Real-world validation (94% physics check pass rate)

The result: 1,247 bullets now have accurate drag models that didn't exist before. Not bad for a side project.

Read the full technical whitepaper for mathematical derivations, validation details, and complete bibliography: cdm_transfer_learning.pdf


Resources

References: 1. McCoy, R. L. (1999). Modern Exterior Ballistics. Schiffer Publishing. 2. Litz, B. (2016). Applied Ballistics for Long Range Shooting (3rd ed.).