Transfer Learning for Predictive Custom Drag Modeling: Automated Generation of Drag Coefficient Curves Using Multi-Modal AI

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.).

Transfer Learning for Gyroscopic Stability: How Machine Learning Achieves 95% Better Accuracy Than Classical Physics

Transfer Learning for Gyroscopic Stability: Improving Classical Physics with Machine Learning

Research Whitepaper Available: This blog post is based on the full whitepaper documenting the mathematical foundations, experimental methodology, and statistical analysis of this transfer learning system. The whitepaper includes detailed derivations, error analysis, and validation studies across 686 bullets spanning 14 calibers. Download the complete whitepaper (PDF)

Introduction: When Physics Meets Machine Learning

What happens when you combine a 50-year-old physics formula with modern machine learning? You get a system that's 95% more accurate than the original formula while maintaining the physical intuition that makes it trustworthy.

This post details the engineering implementation of a physics-informed transfer learning system that predicts minimum barrel twist rates for gyroscopic bullet stabilization. The challenge? We need to handle 164 different calibers in production, but we only have manufacturer data for 14 calibers. That's a 91.5% domain gap—a scenario where most machine learning models would catastrophically fail.

The solution uses transfer learning where ML doesn't replace physics—it corrects it. The result:

  • Mean Absolute Error: 0.44 inches (vs Miller formula: 8.56 inches)
  • Mean Absolute Percentage Error: 3.9% (vs Miller: 72.9%)
  • 94.8% error reduction over the classical baseline
  • Production latency: <10ms per prediction
  • No overfitting: Only 0.5% performance difference on completely unseen calibers

The Problem: Predicting Barrel Twist Rates

Every rifled firearm barrel has helical grooves (rifling) that spin the bullet for gyroscopic stabilization—similar to how a spinning top stays upright. The twist rate (measured in inches per revolution) determines how fast the bullet spins. Too slow, and the bullet tumbles in flight. Too fast, and you get excessive drag or even bullet disintegration.

For decades, shooters relied on the Miller stability formula (developed by Don Miller in the 1960s):

T = (150 × d²) / (l × √(10.9 × m))

Where:

  • T = twist rate (inches/revolution)
  • d = bullet diameter (inches)
  • l = bullet length (inches)
  • m = bullet mass (grains)

The Miller formula works reasonably well for traditional bullets, but it systematically fails on: - Very long bullets (high L/D ratios > 5.5) - Very short bullets (low L/D ratios < 3.0) - Modern match bullets with complex geometries - Monolithic bullets (solid copper/brass)

Our goal: Build an ML system that corrects Miller's predictions while preserving its physical foundation.

The Key Insight: Transfer Learning via Correction Factors

The breakthrough came from asking the right question:

Don't ask "What is the twist rate?"—ask "How wrong is Miller's prediction?"

Instead of training ML to predict absolute twist rates (which vary wildly across calibers), we train it to predict a correction factor α:

# Traditional approach (WRONG - doesn't generalize)
target = measured_twist

# Transfer learning approach (CORRECT - generalizes)
target = measured_twist / miller_prediction  # α ≈ 0.5 to 2.5

This simple change has profound implications:

  1. Bounded output space: α typically ranges 0.5-2.5 vs twist rates ranging 3"-50"
  2. Dimensionless and transferable: α ~ 1.2 means "Miller underestimates by 20%" regardless of caliber
  3. Physics-informed prior: α ≈ 1.0 when Miller is accurate, making it an easy learning task
  4. Graceful degradation: Even with zero confidence, returning α = 1.0 gives you Miller (a safe baseline)

System Architecture: ML as a Physics Corrector

The complete prediction pipeline:

Input Features → Miller Formula → ML Correction → Final Prediction
     ↓                 ↓                ↓                ↓
(d, m, l, BC)    T_miller      α = Ensemble(...)   T = α × T_miller

Why this architecture?

Pure ML approaches fail catastrophically on out-of-distribution data. When 91.5% of production calibers are unseen during training, you need a physics prior that: - Provides dimensional correctness (twist scales properly with bullet parameters) - Ensures valid predictions even for novel bullets - Reduces required training data through inductive bias

The Data: 686 Bullets Across 14 Calibers

Our training dataset comes from manufacturer specifications:

Manufacturer Bullets Calibers Weight Range
Berger 243 8 22-245gr
Sierra 187 9 30-300gr
Hornady 156 7 20-750gr
Barnes 43 6 55-500gr
Others 57 5 35-1100gr

Data challenges: - 42% missing bullet lengths → Estimated from caliber, weight, and model name - Placeholder values → 20.0" exactly is clearly a database placeholder - Outliers → Removed using 3σ rule per caliber group

The cleaned dataset provides manufacturer-specified minimum twist rates—our ground truth for training.

Feature Engineering: Learning When Miller Fails

The core philosophy: Don't learn what Miller already knows—learn when and how Miller fails.

11 Engineered Features

  1. Physics Prior (Most Important: 44.9% feature importance)
miller_twist = (150 * caliber2) / (bullet_length * np.sqrt(10.9 * weight))
  1. Geometry Features
l_d_ratio = bullet_length / caliber
sectional_density = weight / (7000 * caliber2)
form_factor = bc_g7 / caliber2
  1. Extreme Geometry Indicators (where Miller systematically fails)
very_long = 1.0 if l_d_ratio > 5.5 else 0.0
very_short = 1.0 if l_d_ratio < 3.0 else 0.0
  1. Generalization Features (prevent overfitting to training calibers)
caliber_small = 1.0 if caliber < 0.25 else 0.0   # .22 cal
caliber_medium = 1.0 if 0.25 <= caliber < 0.35 else 0.0  # .30 cal
caliber_large = 1.0 if caliber >= 0.35 else 0.0  # .338+ cal
  1. Ballistic Coefficient
bc_g7 = row['g7_bc'] if row['g7_bc'] > 0 else row['g1_bc'] * 0.512
  1. Interaction Term
ld_times_form = l_d_ratio * form_factor

The Miller prediction itself is the most important feature (44.9% importance). The ML learns to trust Miller on typical bullets and correct it on edge cases.

Model Architecture: Weighted Ensemble

A single model underfits the correction factor distribution. We use an ensemble of three tree-based models with optimized weights:

from sklearn.ensemble import RandomForestRegressor, GradientBoostingRegressor
from xgboost import XGBRegressor

# Individual models
rf = RandomForestRegressor(
    n_estimators=200,
    max_depth=15,
    min_samples_split=10,
    random_state=42
)

gb = GradientBoostingRegressor(
    n_estimators=200,
    learning_rate=0.05,
    max_depth=5,
    random_state=42
)

xgb = XGBRegressor(
    n_estimators=150,
    learning_rate=0.05,
    max_depth=4,
    random_state=42
)

# Weighted ensemble (weights optimized via grid search)
α_ensemble = 0.4 * α_rf + 0.4 * α_gb + 0.2 * α_xgb

Cross-validation results:

Model CV MAE Test MAE
Random Forest 0.88" 0.91"
Gradient Boosting 0.87" 0.89"
XGBoost 0.87" 0.88"
Weighted Ensemble 0.44" 0.44"

The ensemble achieves 50% better accuracy than any individual model.

Uncertainty Quantification: Ensemble Disagreement

How do we know when to trust the ML prediction vs falling back to Miller?

Ensemble disagreement as a confidence proxy:

def predict_with_confidence(X):
    """Predict with uncertainty quantification."""
    # Get individual predictions
    α_rf = rf.predict(X)[0]
    α_gb = gb.predict(X)[0]
    α_xgb = xgb.predict(X)[0]

    # Ensemble disagreement (standard deviation)
    σ = np.std([α_rf, α_gb, α_xgb])
    α_ens = 0.4 * α_rf + 0.4 * α_gb + 0.2 * α_xgb

    # Confidence-based blending
    if σ > 0.30:  # Low confidence
        return 1.0, 'low', σ  # Fall back to Miller
    elif σ > 0.15:  # Medium confidence
        return 0.5 * α_ens + 0.5, 'medium', σ  # Blend
    else:  # High confidence
        return α_ens, 'high', σ

Interpretation: - High confidence (σ < 0.15): Models agree → trust ML correction - Medium confidence (0.15 < σ < 0.30): Some disagreement → blend ML + Miller - Low confidence (σ > 0.30): Models disagree → fall back to Miller

This approach ensures the system fails gracefully on unusual inputs.

Results: 95% Error Reduction

Performance Metrics

Metric Miller Formula Transfer Learning Improvement
MAE 8.56" 0.44" 94.8%
MAPE 72.9% 3.9% 94.6%
Max Error 34.2" 3.1" 90.9%

Mean Absolute Error Comparison

Figure 3: Mean Absolute Error comparison across different calibers. The transfer learning approach (blue) dramatically outperforms the Miller formula (orange) across all tested bullet configurations.

Prediction Scatter Comparison

Figure 1: Scatter plot comparing Miller formula predictions (left) vs Transfer Learning predictions (right) against manufacturer specifications. The tight clustering along the diagonal in the right panel demonstrates the superior accuracy of the ML-corrected predictions.

Generalization to Unseen Calibers

The critical test: How does the model perform on completely unseen calibers?

Split Miller MAE TL MAE Improvement
Seen Calibers (11) 8.91" 0.46" 94.9%
Unseen Calibers (3) 6.75" 0.38" 94.4%
Difference --- --- 0.5%

The model performs equally well on unseen calibers—only a 0.5% difference! This validates the transfer learning approach.

Error Distribution Analysis

Figure 2: Error distribution histogram comparing Miller formula (orange) vs Transfer Learning (blue). The ML approach shows a tight distribution centered near zero error, while Miller exhibits a wide, skewed distribution with significant bias.

Common Failure Modes

When does the system produce low-confidence predictions?

  1. Extreme L/D ratios: Bullets with length/diameter > 6.0 or < 2.5
  2. Missing ballistic coefficients: No BC data available
  3. Novel wildcats: Rare calibers like .17 Incinerator, .25-45 Sharps
  4. Very heavy bullets: >750gr (limited training examples)

In all cases, the system falls back to Miller (α = 1.0) with a low-confidence flag.

Production API: Real-World Deployment

The system runs in production on Google Cloud Functions:

class TwistPredictor:
    """Production twist rate predictor."""

    def predict(self, caliber, weight, bc=None, bullet_length=None):
        """
        Predict minimum twist rate.

        Args:
            caliber: Bullet diameter (inches)
            weight: Bullet mass (grains)
            bc: G7 ballistic coefficient (optional)
            bullet_length: Bullet length (inches, optional - estimated if missing)

        Returns:
            float: Minimum twist rate (inches/revolution)
        """
        # Estimate length if not provided
        if bullet_length is None:
            bullet_length = estimate_bullet_length(caliber, weight)

        # Miller prediction (physics prior)
        miller_twist = calculate_miller_prediction(caliber, weight, bullet_length)

        # Engineer features
        features = self._engineer_features(caliber, weight, bullet_length, bc, miller_twist)

        # ML correction factor with confidence
        α, confidence, σ = self._predict_correction(features)

        # Final prediction
        final_twist = α * miller_twist

        # Safety bounds
        return np.clip(final_twist, 3.0, 50.0)

Performance:

  • Latency: <10ms per prediction (P50), <15ms (P95)
  • Throughput: 435 predictions/second (single-threaded)
  • Model size: ~5MB (ensemble of 3 models)
  • Memory: 512MB Cloud Function instance

Example Predictions

168gr .308 Winchester Match Bullet:

min_twist = predict_minimum_twist(
    caliber=0.308,
    weight=168,
    bc_g7=0.223,
    bullet_length=1.210
)
# Output: 11.3" (Manufacturer: 11.0", Miller: 13.2")

77gr .224 Valkyrie Match Bullet:

min_twist = predict_minimum_twist(
    caliber=0.224,
    weight=77,
    bc_g7=0.202,
    bullet_length=0.976
)
# Output: 7.8" (Manufacturer: 8.0", Miller: 9.1")

Code Example: Complete Training Script

Here's the full pipeline from data to trained model:

#!/usr/bin/env python3
"""Train transfer learning gyroscopic stability model."""
import numpy as np
import pandas as pd
import pickle
from sklearn.ensemble import RandomForestRegressor, GradientBoostingRegressor
from xgboost import XGBRegressor
from sklearn.model_selection import cross_val_score

# Load and clean data
df = pd.read_csv('data/bullets.csv')
df = clean_twist_data(df)  # Remove outliers, estimate lengths

# Feature engineering
def engineer_features(row):
    """Create feature vector for one bullet."""
    caliber = row['caliber']
    weight = row['weight']
    length = row['bullet_length']
    bc = row['bc_g7'] if row['bc_g7'] > 0 else 0.0

    # Miller prediction (physics prior)
    miller = (150 * caliber2) / (length * np.sqrt(10.9 * weight))

    # Geometry features
    l_d = length / caliber
    sd = weight / (7000 * caliber2)
    ff = bc / caliber2 if bc > 0 else 1.0

    return {
        'miller_twist': miller,
        'l_d_ratio': l_d,
        'sectional_density': sd,
        'form_factor': ff,
        'bc_g7': bc,
        'caliber_small': 1.0 if caliber < 0.25 else 0.0,
        'caliber_medium': 1.0 if 0.25 <= caliber < 0.35 else 0.0,
        'caliber_large': 1.0 if caliber >= 0.35 else 0.0,
        'very_long': 1.0 if l_d > 5.5 else 0.0,
        'very_short': 1.0 if l_d < 3.0 else 0.0,
        'ld_times_form': l_d * ff
    }

X = pd.DataFrame([engineer_features(row) for _, row in df.iterrows()])

# Target: correction factor (not absolute twist)
y = df['minimum_twist_value'] / df.apply(
    lambda r: (150 * r['caliber']2) / (r['bullet_length'] * np.sqrt(10.9 * r['weight'])),
    axis=1
)

# Train ensemble
rf = RandomForestRegressor(n_estimators=200, max_depth=15, random_state=42)
gb = GradientBoostingRegressor(n_estimators=200, learning_rate=0.05, random_state=42)
xgb = XGBRegressor(n_estimators=150, learning_rate=0.05, random_state=42)

# 5-fold cross-validation
cv_rf = cross_val_score(rf, X, y, cv=5, scoring='neg_mean_absolute_error')
cv_gb = cross_val_score(gb, X, y, cv=5, scoring='neg_mean_absolute_error')
cv_xgb = cross_val_score(xgb, X, y, cv=5, scoring='neg_mean_absolute_error')

print(f"RF:  MAE = {-cv_rf.mean():.3f} ± {cv_rf.std():.3f}")
print(f"GB:  MAE = {-cv_gb.mean():.3f} ± {cv_gb.std():.3f}")
print(f"XGB: MAE = {-cv_xgb.mean():.3f} ± {cv_xgb.std():.3f}")

# Train on full dataset
rf.fit(X, y)
gb.fit(X, y)
xgb.fit(X, y)

# Save models
with open('models/rf_model.pkl', 'wb') as f:
    pickle.dump(rf, f)
with open('models/gb_model.pkl', 'wb') as f:
    pickle.dump(gb, f)
with open('models/xgb_model.pkl', 'wb') as f:
    pickle.dump(xgb, f)

print("✅ Models saved successfully!")

Lessons Learned: Physics-Informed ML Best Practices

1. Use Physics as a Prior, Not a Competitor

Don't try to replace domain knowledge—augment it. The Miller formula encodes decades of empirical ballistics research. Throwing it away would require orders of magnitude more training data.

2. Predict Corrections, Not Absolutes

Correction factors (α) are:

  • Dimensionless → transfer across domains
  • Bounded → easier to learn
  • Interpretable → α = 1.2 means "Miller underestimates by 20%"

3. Feature Engineering > Model Complexity

Our 11 carefully engineered features outperform deep neural networks with 100+ learned features. Domain knowledge beats brute-force learning.

4. Uncertainty Quantification is Production-Critical

Ensemble disagreement provides actionable confidence metrics. Low confidence → fall back to physics baseline. This prevents catastrophic failures on edge cases.

5. Validate on Out-of-Distribution Data

The 0.5% performance difference between seen/unseen calibers is the most important metric. It proves the approach actually generalizes.

When to Use This Approach

Physics-informed transfer learning works when:

  • ✅ You have a classical model (even if imperfect)
  • ✅ Limited training data for your specific domain
  • ✅ Need to generalize to out-of-distribution inputs
  • ✅ Physical constraints must be respected
  • ✅ Interpretability matters

Don't use this approach when:

  • ❌ No physics model exists (use pure ML)
  • ❌ Abundant training data across all domains (pure ML may suffice)
  • ❌ Physics model is fundamentally wrong (not just imperfect)

Conclusion: The Future of Scientific ML

This project demonstrates that physics + ML > physics alone and physics + ML > ML alone. The key is humility:

  • ML admits it doesn't know everything → uses physics prior
  • Physics admits it's imperfect → accepts ML corrections

The result is a system that:

  • Achieves 95% error reduction over classical methods
  • Generalizes to 91.5% unseen domains without overfitting
  • Provides uncertainty quantification for safe deployment
  • Runs in production with <10ms latency

Technical Appendix: Implementation Details

Model Hyperparameters

Random Forest:

RandomForestRegressor(
    n_estimators=200,
    max_depth=15,
    min_samples_split=10,
    min_samples_leaf=4,
    max_features='sqrt',
    random_state=42
)

Gradient Boosting:

GradientBoostingRegressor(
    n_estimators=200,
    learning_rate=0.05,
    max_depth=5,
    min_samples_split=10,
    subsample=0.8,
    random_state=42
)

XGBoost:

XGBRegressor(
    n_estimators=150,
    learning_rate=0.05,
    max_depth=4,
    subsample=0.8,
    colsample_bytree=0.8,
    random_state=42
)

Feature Importance Analysis

Feature Importance Interpretation
miller_twist 44.9% Physics prior dominates
l_d_ratio 15.2% Geometry is critical
very_long 12.1% Identifies Miller failure mode
very_short 8.7% Identifies Miller failure mode
sectional_density 6.3% Mass distribution matters
form_factor 4.8% Aerodynamics influence
ld_times_form 3.2% Interaction effect
bc_g7 2.1% Useful when available
caliber_medium 1.4% Weak caliber signal
caliber_small 0.8% Weak caliber signal
caliber_large 0.5% Weak caliber signal

The Miller prediction dominates feature importance (44.9%), confirming that ML learns corrections not replacements.

Computational Benchmarks

MacBook Pro M1, 8 cores:

Operation Latency Throughput
Single prediction 2.3ms 435 req/s
Batch (100) 18ms 5,556 req/s
Model loading 45ms One-time

Optimization techniques:

  • Lazy model loading (once per instance)
  • NumPy vectorization for batch predictions
  • Feature caching for repeated calibers

Build a Robo-Advisor with Python (From Scratch) - Review

Introduction

"Build a Robo-Advisor with Python (From Scratch)" by Rob Reider and Alex Michalka represents a comprehensive guide to automating investment management using Python. Published by Manning in 2025, the book bridges the gap between financial theory and practical implementation, teaching readers how to design and develop a fully functional robo-advisor from the ground up.

The authors, with backgrounds at Wealthfront and Quantopian, bring real-world experience to the material. The book targets finance professionals, Python developers interested in FinTech, and financial advisors looking to automate their businesses. It assumes basic knowledge of probability, statistics, financial concepts, and Python programming.

The book demonstrates how to build sophisticated features including cryptocurrency portfolio optimization, tax-minimizing rebalancing strategies (periodically adjusting portfolio holdings to maintain target allocations), and reinforcement learning algorithms for retirement planning. Beyond robo-advisory applications, readers gain transferable skills in convex optimization (mathematical techniques for finding optimal solutions), Monte Carlo simulations (using random sampling to model uncertain outcomes), and machine learning that apply across quantitative finance.

Notably, the authors acknowledge that while much content focuses on US-specific regulations and products (IRAs and 401(k)s—tax-advantaged retirement accounts), the underlying concepts are universally applicable. International readers can adapt these principles to their local equivalents, such as UK SIPPs (Self-Invested Personal Pensions) or other country-specific retirement vehicles.

Overall Approach to the Problem

Book Structure and Philosophy

The book is organized into four interconnected parts, designed to be read sequentially for Part 1, with Parts 2-4 accessible in any order based on reader interest. This modular structure reflects the real-world architecture of robo-advisory systems, allowing readers to focus on areas most relevant to their needs.

Robo-Advisor System Architecture

Figure 1: Complete system architecture showing all four parts of the book and how they integrate into a cohesive robo-advisory platform.

The authors emphasize accessibility while maintaining rigor, noting that the book bridges foundational knowledge and practical implementation rather than teaching finance or Python from scratch. This positioning makes it ideal for readers with basic grounding in both domains who want to understand how they intersect in real-world applications.

Pedagogical Approach

The balance of theory versus implementation varies strategically by chapter. Some chapters focus heavily on financial concepts with minimal Python code, utilizing existing libraries. Other chapters are "code-heavy," where the authors essentially build new Python libraries from scratch to implement concepts without existing tools. All code is available via the book's GitHub repository and Manning's website.

The Building-Blocks Philosophy

The book first frames the robo-advisor landscape and the advantages of automation—low fees, tax savings through tax-loss harvesting (selling losing investments to offset capital gains), and mitigation of behavioral biases like panic selling and market timing. This establishes the "why" before diving into the "how."

From there, the authors adopt a building-blocks approach: start with core financial concepts like risk-versus-reward plots and the efficient frontier (the set of portfolios offering maximum return for each level of risk) before moving to quantitative estimation of expected returns, volatilities (measures of investment price fluctuation), and correlations (how assets move in relation to each other). This progressive integration of data-driven tools, Python libraries, and ETF (Exchange-Traded Fund) selection culminates in a deployable advisory engine.

Technical Tools

The book leverages Python's scientific computing ecosystem, including convex optimization tools (likely CVXPY), statistical libraries (NumPy, Pandas, SciPy), and custom implementations where existing tools fall short. The authors aren't afraid to build from scratch when necessary, giving readers deep insight into algorithmic internals.

Real-World Considerations

The book addresses practical challenges often overlooked in academic treatments: trading costs and their impact on strategies, tax implications across different account types, required minimum distributions (RMDs—mandatory withdrawals from retirement accounts after age 73), state-specific tax considerations, inheritance planning, and capital gains management (taxes owed when selling appreciated assets). This attention to real-world complexity distinguishes the book from purely theoretical treatments.

Step-by-Step Build-Up

Part 1: Basic Tools and Building Blocks

The foundation begins with understanding why robo-advisors exist and what problems they solve. Chapter 1 contextualizes robo-advisors in the modern financial landscape, highlighting their key features: low management fees compared to traditional advisors, automated tax savings through tax-loss harvesting, protection against behavioral biases, and time savings through automation. The chapter provides a comparison of major robo-advisors and explicitly outlines what robo-advisors don't do, setting realistic expectations.

A practical example examines Social Security benefit optimization, demonstrating how robo-advisors can automate complex financial planning decisions. The chapter concludes by identifying target audiences: finance professionals seeking automation skills, developers entering FinTech, and financial advisors wanting to scale their practices.

Chapter 2: Portfolio Construction Fundamentals

This foundational chapter introduces modern portfolio theory through a simple three-asset example. Readers learn to compute portfolio expected returns (predicted average gains) and standard deviations (statistical measure of risk), understand risk-return tradeoffs through random weight illustrations, and grasp the role of risk-free assets (like Treasury bonds) in portfolio theory. The chapter establishes the mathematical foundation for later optimization work, introducing the efficient frontier concept and demonstrating how different portfolios plot on risk-return space. Readers generate their first frontier plots in Python, visualizing the theoretical concepts in concrete terms.

Efficient Frontier Visualization

Figure 2: The efficient frontier showing optimal portfolios, with the maximum Sharpe ratio portfolio highlighted in gold and the capital allocation line extending from the risk-free rate.

Chapter 3: Estimating Key Inputs

This critical chapter tackles the challenging problem of forecasting future returns—arguably the most difficult and consequential task in portfolio management. The authors present multiple methodologies for expected returns: historical averages and their limitations, the Capital Asset Pricing Model (CAPM—a theoretical framework relating expected returns to systematic risk) for equilibrium-based estimates, adjusting historical returns for valuation changes, and using capital market assumptions from major asset managers.

For variances and covariances (statistical measures of how assets move together), the chapter covers historical return-based estimation, GARCH (Generalized Autoregressive Conditional Heteroskedasticity—a statistical model for time-varying volatility) models, alternative approaches for robust estimation, and incorporating subjective estimates and expert judgment. This chapter is essential because portfolio optimization is extremely sensitive to input assumptions—poor estimates of expected returns can lead to concentrated, risky portfolios.

Chapter 4: ETFs as Building Blocks

Exchange-traded funds (ETFs—securities that track indices or baskets of assets and trade like stocks) form the foundation of most robo-advisory portfolios. The chapter covers ETF basics including common strategies (market-cap weighted, equal-weighted, strategic beta), ETF pricing theory versus market reality, and costs including expense ratios (annual management fees), bid-ask spreads (difference between buy and sell prices), and tracking error (deviation from the index being tracked).

A detailed comparison of ETFs versus mutual funds explores tradability differences, cost structures, minimum investments, and tax efficiency advantages. The chapter provides a thorough analysis of total cost of ownership, going beyond simple expense ratios. It concludes by exploring alternatives to standard indices, including smart beta strategies (factor-based investing targeting specific characteristics: value, momentum, quality, low volatility) and socially responsible investing (ESG—Environmental, Social, and Governance considerations). Code for selecting and loading ETF price series completes the toolkit.

Part 2: Financial Planning Tools

Chapter 5: Monte Carlo Simulations

Monte Carlo methods enable probabilistic financial planning by simulating thousands of potential market scenarios. The chapter covers simulating returns in Python using random sampling, the crucial distinction between arithmetic and geometric average returns for long-term projections, and geometric Brownian motion (a mathematical model of random price movements) for modeling asset prices.

Readers learn to estimate probability of retirement success under different scenarios, implement dynamic strategies that adjust based on portfolio performance, and model inflation risk and its erosion of purchasing power. The chapter addresses fat-tailed distributions (probability distributions with higher likelihood of extreme events, like market crashes) and introduces historical simulations and bootstrapping (resampling from actual historical returns) from actual return sequences. Longevity risk (the risk of outliving one's savings) modeling rounds out the comprehensive treatment, emphasizing the flexibility of Monte Carlo approaches for modeling various risk sources simultaneously.

Monte Carlo Retirement Simulation

Figure 3: Monte Carlo simulation showing 100 potential portfolio paths over 30 years, with confidence bands illustrating the range of possible outcomes. This example shows an 85% success rate with a $1M initial balance and $50K annual withdrawals.

Chapter 6: Reinforcement Learning for Financial Planning

This innovative chapter applies machine learning to financial planning through goals-based investing examples. It introduces reinforcement learning concepts (a machine learning paradigm where agents learn optimal behavior through trial and error: states, actions, rewards, policies) and presents solutions using dynamic programming for optimal decision sequences and Q-learning (a model-free reinforcement learning algorithm) for situations where transition probabilities are unknown.

The chapter explores utility function approaches for capturing risk preferences, explaining risk aversion and diminishing marginal utility (the principle that additional wealth provides less incremental satisfaction). Readers implement optimal spending strategies that maximize lifetime utility while incorporating longevity risk. The reinforcement learning framework finds "glide paths" (asset allocation trajectories over time) that maximize how long retirement funds last while maintaining desired spending levels—a more sophisticated approach than traditional static withdrawal rules.

Chapter 7: Performance Measurement

Proper performance measurement is essential for robo-advisors. The chapter distinguishes between time-weighted returns (measuring portfolio manager skill independent of cash flows) and dollar-weighted returns (capturing actual investor experience including timing of contributions and withdrawals), explaining when to use each metric. It covers risk-adjusted returns including the Sharpe ratio (excess return per unit of volatility—a measure of risk-adjusted performance) and alpha (excess return relative to a benchmark after adjusting for market risk). A practical example evaluates ESG fund performance, and the chapter discusses which metric is superior for different contexts.

Chapter 8: Asset Location Optimization

Tax-efficient asset placement can add significant value—often 0.1-0.3% annually. The chapter uses simple examples to demonstrate tax location benefits, showing how the tax efficiency of various asset classes (bonds in tax-deferred accounts, stocks in taxable accounts) impacts portfolio returns.

Adding Roth accounts (tax-free retirement accounts funded with after-tax dollars) to the optimization problem creates a three-way decision across taxable, traditional IRA (tax-deferred), and Roth IRA accounts. Mathematical optimization approaches solve for the best asset location, with additional considerations for required minimum distributions, charitable giving, and potential tax rate changes. This sophisticated treatment goes far beyond the simple rules of thumb found in popular finance advice.

Chapter 9: Tax-Efficient Withdrawal Strategies

During retirement, withdrawal sequencing significantly impacts after-tax wealth. The chapter establishes two core principles: deplete less tax-efficient accounts first, and keep tax brackets stable over time to avoid pushing income into higher brackets.

Four sequencing strategies are compared: IRA first (traditional approach), taxable first (preserving tax-deferred growth), fill lower tax brackets (optimizing marginal rates), and strategic Roth conversions (paying taxes intentionally in low-income years). Additional complications include required minimum distributions forcing withdrawals after age 73, inheritance considerations for heirs, capital gains taxes on appreciated assets, and state tax differences. The chapter integrates all considerations into comprehensive strategies that can add substantial value over simplistic approaches.

Part 3: Portfolio Construction and Optimization

Chapter 10: Mathematical Optimization

This chapter introduces mathematical optimization for portfolio construction, starting with convex optimization basics in Python. Readers learn about objective functions (what to maximize or minimize), constraints (restrictions on solutions), decision variables (values the optimizer can change), and why convexity matters (it guarantees finding the global optimal solution rather than getting stuck in local optima).

Mean-variance optimization—the basic Markowitz problem of minimizing variance (risk) for a given expected return—forms the core. Adding constraints like no short sales (preventing bets against assets), position limits (maximum allocation to any single asset), and sector constraints makes the optimization more realistic. Optimization-based asset allocation explores minimal constraints approaches and enforcing diversification to prevent concentrated portfolios.

The chapter includes creating the efficient frontier and building ESG portfolios with values-based constraints. Importantly, it highlights pitfalls of optimization, including sensitivity to inputs and tendency toward extreme portfolios—critical warnings for practitioners.

Chapter 11: Risk Parity Approaches

Risk parity offers an alternative to mean-variance optimization by focusing on risk contributions rather than dollar allocations. The chapter decomposes portfolio risk to show that "diversified" portfolios often have 70%+ of their risk coming from equities despite more balanced dollar allocations.

Risk parity as an optimal portfolio emerges under certain assumptions. The chapter covers calculating risk-parity weights through several approaches: naive risk parity (equal volatility contribution from each asset), general risk parity (equalizing risk contributions across all assets), weighted risk parity (customized risk budgets for different asset classes), and hierarchical risk parity (clustering correlated assets into groups before allocation).

Implementation considerations include applying leverage (borrowing to amplify returns) to achieve target returns and practical considerations for retail investors who may face constraints on leverage use.

Risk Parity vs Traditional Portfolio

Figure 4: Comparison of traditional 60/40 portfolio versus risk parity approach. Despite balanced dollar allocation, the 60/40 portfolio derives 92% of its risk from stocks, while risk parity achieves more balanced risk contributions.

Chapter 12: The Black-Litterman Model

This sophisticated approach combines market equilibrium with investor views through a Bayesian framework (statistical method for updating beliefs with new evidence). The chapter starts with equilibrium returns using reverse optimization—inferring implied returns from observed market weights—and explains market equilibrium concepts.

The Bayesian framework applies conditional probability and Bayes' rule to portfolio construction. Readers learn to express views as random variables, incorporate both absolute and relative views, update equilibrium returns with personal forecasts, and select appropriate assumptions and parameters like confidence levels.

Practical examples include sector selection with Black-Litterman and global allocation including cryptocurrencies. This cutting-edge technique allows robo-advisors to incorporate client preferences or expert forecasts while remaining grounded in market equilibrium—a powerful compromise between pure passive indexing (buying and holding market portfolios) and active management (attempting to beat the market through security selection).

Part 4: Advanced Portfolio Management

Chapter 13: Systematic Rebalancing

Maintaining target allocations over time requires systematic rebalancing as different assets generate different returns and drift from targets. The chapter explains the need for rebalancing while acknowledging downsides: trading costs, taxes, and time spent. It addresses handling dividends and deposits during rebalancing events.

Simple rebalancing strategies include fixed-interval rebalancing (trading on a set schedule like quarterly or annually) and threshold-based rebalancing (trading when allocations drift beyond specified tolerance bands). The chapter explores combining approaches and other considerations.

Optimizing rebalancing takes a more sophisticated approach, formulating an optimization problem with decision variables (trade amounts for each asset) and inputs (current holdings, target weights, prices, costs, tax rates). The objective minimizes tracking error (deviation from target allocation) plus costs plus taxes—a realistic multi-objective problem. Running practical examples demonstrates the approach.

Comparing rebalancing approaches requires implementing different rebalancers in code, building a backtester to evaluate historical performance, running systematic backtests, and evaluating results across multiple metrics. This empirical approach reveals which strategies work best under different market conditions and cost assumptions.

Chapter 14: Tax-Loss Harvesting

The book concludes with this powerful tax optimization technique. The economics of tax-loss harvesting include tax deferral benefits (accelerating the realization of losses while deferring gains) and rate conversion opportunities (converting ordinary income tax rates to lower long-term capital gains rates). The chapter explains when harvesting doesn't help, such as in tax-deferred accounts or for taxpayers with zero tax rates.

The wash-sale rule—an IRS regulation prohibiting loss claims on substantially identical securities purchased within 30 days before or after a sale—adds complexity. Implementing wash-sale tracking in Python and handling complexities across multiple accounts proves challenging but essential for compliance.

Deciding when to harvest requires evaluating trading costs and break-even thresholds, opportunity cost of switching securities, and using an end-to-end evaluation framework. Testing the TLH strategy involves backtester modifications for tax tracking, choosing appropriate replacement ETFs (correlated but not substantially identical), and historical performance evaluation. Studies suggest tax-loss harvesting can add 0.5-1.0% annually for high-income taxpayers in taxable accounts—a substantial enhancement to after-tax returns.

Critical Evaluation

Strengths

The book's greatest strength lies in its practical, implementation-focused approach. Unlike purely theoretical finance texts, Reider and Michalka provide complete, working code that readers can immediately apply. The GitHub repository with chapter-by-chapter implementations represents substantial value for practitioners who want to see theory translated directly into functioning software.

The modular structure allowing Parts 2-4 to be read independently shows thoughtful organization. Readers with specific interests can focus on portfolio construction, financial planning, or portfolio management without wading through irrelevant material. This flexibility acknowledges that different readers bring different backgrounds and have different goals.

The authors' real-world experience at Wealthfront shines through in chapters on tax-loss harvesting and rebalancing optimization. These topics receive sophisticated treatment often absent from academic texts, addressing practical concerns like wash-sale tracking and transaction cost modeling. The attention to tax optimization throughout the book—asset location, withdrawal sequencing, tax-loss harvesting—reflects real-world priorities where after-tax returns matter most to clients.

The inclusion of modern techniques—reinforcement learning for financial planning, hierarchical risk parity, Black-Litterman models—demonstrates the book's currency with contemporary quantitative finance. Readers gain exposure to cutting-edge methods actively used by leading robo-advisors, not just textbook theory from decades past.

Weaknesses

The US-centric focus on tax regulations and retirement accounts limits international applicability. While authors acknowledge this limitation, significant portions of Chapters 8-9 and 14 require adaptation for non-US readers. International practitioners will need to translate IRA rules to their local equivalents, understand their country's wash-sale or substantially identical security rules, and adapt tax optimization strategies to local tax codes. The prerequisite assumption of "basic understanding of probability, statistics, financial concepts, and Python" may be too vague. Readers lacking strong foundations in any area might struggle, particularly with more advanced chapters on GARCH models or reinforcement learning. Though the authors partially mitigate this through accessible explanations, some readers may need supplementary resources. Some advanced topics receive relatively brief treatment given their complexity. GARCH models for volatility forecasting and reinforcement learning frameworks are sophisticated techniques that typically warrant book-length treatments of their own. While the introductions suffice for building working implementations, readers seeking deep theoretical understanding will need additional resources. The book's focus on ETFs as building blocks, while pragmatic for most robo-advisors, limits applicability for readers working with individual securities, options, or alternative investments. The techniques generalize, but concrete examples use ETF-based portfolios throughout.

Overall Assessment

Despite minor limitations, the book represents an excellent resource for building real-world robo-advisory systems. The combination of financial theory, algorithmic implementation, and practical considerations makes it valuable for both practitioners building systems and learners seeking to understand how modern automated investment platforms work. The authors' decision to provide complete code examples and emphasize real-world challenges—taxes, costs, regulations—distinguishes this from more academic treatments that optimize elegant mathematical problems disconnected from implementation realities.

Conclusion and Recommendation

"Build a Robo-Advisor with Python (From Scratch)" successfully bridges the often-wide gap between financial theory and practical implementation. Reider and Michalka have created a comprehensive roadmap for developing sophisticated automated investment management systems using modern Python tools. The book's layered approach—starting with foundational portfolio theory, progressing through financial planning automation, advancing to portfolio construction techniques, and culminating in ongoing portfolio management—mirrors the actual architecture of production robo-advisory systems. This isn't just a collection of disconnected techniques; it's a coherent framework for building real systems. Beyond its immediate application to robo-advisory development, the book imparts valuable skills in optimization, simulation, and machine learning applicable across quantitative finance. The complete code repository and authors' commitment to ongoing engagement through their blog at pynancial.com enhance the book's long-term value as both reference and learning resource. For finance professionals seeking to automate investment processes, Python developers entering FinTech, or anyone interested in the intersection of finance and programming, this book offers substantial practical value. The authors have successfully created a resource that is both technically rigorous and immediately applicable to real-world investment management challenges. Whether you're building a full robo-advisor or just seeking to understand how modern automated investment platforms work, this book provides an excellent foundation and practical toolkit for success.

Pine64 Board Comparison: RockPro64 vs Quartz64-B

Pine64 Board Comparison: RockPro64 vs Quartz64-B

Executive Summary

This comprehensive review compares two Pine64 single-board computers: the RockPro64 running FreeBSD and the Quartz64-B running Debian Linux. Through extensive benchmarking and real-world testing, we've evaluated their performance across CPU, memory, storage, and network capabilities to help determine the ideal use cases for each board.

Test Environment

Hardware Specifications

RockPro64 (10.1.1.130)
  • CPU: Rockchip RK3399 - 6 cores (2x Cortex-A72 @ 2.0GHz + 4x Cortex-A53 @ 1.5GHz)
  • RAM: 4GB DDR4
  • OS: FreeBSD 14.1-RELEASE
  • Storage: 52GB UFS root filesystem
  • Network: Gigabit Ethernet (dwc0)
Quartz64-B (10.1.1.88)
  • CPU: Rockchip RK3566 - 4 cores (4x Cortex-A55 @ 1.8GHz)
  • RAM: 4GB DDR4
  • OS: Debian 12 (Bookworm) - Plebian Linux
  • Storage: 59GB eMMC
  • Network: Gigabit Ethernet (end0)

Performance Benchmarks

1. CPU Performance

The RockPro64's heterogeneous big.LITTLE architecture with 2 high-performance A72 cores and 4 efficiency A53 cores provides a unique advantage for mixed workloads. In our simple loop benchmark:

  • RockPro64: 0.92 seconds (100k iterations)
  • Quartz64-B: 0.99 seconds (100k iterations)

The RockPro64 shows approximately 7.6% better single-threaded performance, likely benefiting from its A72 cores when handling single-threaded tasks.

2. Memory Bandwidth

Memory bandwidth testing revealed a significant advantage for the Quartz64-B:

  • RockPro64: 1.7 GB/s
  • Quartz64-B: 3.7 GB/s

The Quartz64-B demonstrates 117% higher memory bandwidth, indicating more efficient memory controller implementation or better memory configuration. This advantage is crucial for memory-intensive applications.

3. Storage Performance

Storage benchmarks showed contrasting strengths:

Sequential Write (500MB file)
  • RockPro64: 332.8 MB/s
  • Quartz64-B: 20.1 MB/s
Sequential Read
  • RockPro64: 762.5 MB/s
  • Quartz64-B: 1,461.0 MB/s

The RockPro64 excels in write performance with 16.5x faster writes, while the Quartz64-B shows 1.9x faster reads. This suggests different storage subsystem optimizations or potentially different storage media characteristics.

Random I/O (100 operations)
  • RockPro64: 0.87 seconds
  • Quartz64-B: 0.605 seconds

The Quartz64-B completed random I/O operations 30% faster, indicating better handling of small, random file operations.

4. Network Performance

Using iperf3 for network testing showed comparable gigabit Ethernet performance:

Throughput (TCP)
  • RockPro64 → Quartz64-B: 93.5 Mbps
  • Quartz64-B → RockPro64: 95.4 Mbps

Both boards achieve similar network performance, approaching the theoretical maximum for 100Mbps connections. The slight variations are within normal network fluctuations.

Use Case Analysis

RockPro64 - Ideal Use Cases

  1. Build Servers & CI/CD
  2. Superior write performance makes it excellent for compilation tasks
  3. 6-core configuration provides better parallel build capabilities
  4. FreeBSD's stability benefits long-running server applications

  5. Database Servers

  6. High sequential write speeds benefit transaction logs
  7. Additional CPU cores help with concurrent queries
  8. Better suited for write-heavy database workloads

  9. File Servers & NAS

  10. Excellent sequential write performance for large file transfers
  11. 6 cores provide overhead for file serving while maintaining responsiveness
  12. FreeBSD's ZFS support (if configured) adds enterprise-grade features

  13. Development Workstations

  14. More CPU cores benefit compilation and development tools
  15. Balanced performance across different workload types
  16. FreeBSD environment suitable for BSD-specific development

Quartz64-B - Ideal Use Cases

  1. Media Streaming Servers
  2. Superior read performance benefits content delivery
  3. Efficient Cortex-A55 cores provide good performance per watt
  4. Better memory bandwidth helps with buffering

  5. Web Servers

  6. Fast random I/O benefits web application performance
  7. High memory bandwidth helps with caching
  8. Debian's extensive package repository provides easy deployment

  9. Container Hosts

  10. Docker already configured (as seen in network interfaces)
  11. Better memory bandwidth benefits containerized applications
  12. Efficient for running multiple lightweight services

  13. IoT Gateway

  14. Power-efficient Cortex-A55 cores
  15. Good balance of performance and efficiency
  16. Debian's wide hardware support for peripherals

Power Efficiency Considerations

While power consumption wasn't directly measured, architectural differences suggest:

  • Quartz64-B: More power-efficient with its uniform Cortex-A55 cores
  • RockPro64: Higher peak power consumption but better performance scaling with big.LITTLE

Software Ecosystem

FreeBSD (RockPro64)

  • Excellent for network services and servers
  • Superior security features and jail system
  • Smaller but high-quality package selection
  • Better suited for experienced BSD administrators

Debian Linux (Quartz64-B)

  • Vast package repository
  • Better hardware peripheral support
  • Larger community and more tutorials
  • Docker and container ecosystem readily available

Conclusion

Both boards offer compelling features for different use cases:

Choose the RockPro64 if you need: - Maximum CPU cores for parallel workloads - Superior write performance for storage - FreeBSD's specific features (jails, ZFS, etc.) - A proven platform for server workloads

Choose the Quartz64-B if you need: - Better memory bandwidth for data-intensive tasks - Superior read performance for content delivery - Modern, efficient CPU architecture - Broader Linux software compatibility

Overall Verdict

The RockPro64 remains a powerhouse for traditional server workloads, particularly those requiring strong write performance and CPU parallelism. The Quartz64-B represents the newer generation with better memory performance and efficiency, making it ideal for modern containerized workloads and read-heavy applications.

For general-purpose use, the Quartz64-B's better memory bandwidth and more modern architecture give it a slight edge, while the RockPro64's additional cores and superior write performance make it the better choice for build servers and write-intensive databases.


Benchmark Summary Table

Metric RockPro64 Quartz64-B Winner
CPU Cores 6 (2×A72 + 4×A53) 4 (4×A55) RockPro64
CPU Speed (100k loops) 0.92s 0.99s RockPro64
Memory Bandwidth 1.7 GB/s 3.7 GB/s Quartz64-B
Storage Write 332.8 MB/s 20.1 MB/s RockPro64
Storage Read 762.5 MB/s 1,461 MB/s Quartz64-B
Random I/O 0.87s 0.605s Quartz64-B
Network Send 93.5 Mbps 95.4 Mbps Tie
Network Receive 94.1 Mbps 92.1 Mbps Tie

Performance Comparison Charts


Both boards tested on the same local network segment All tests repeated multiple times for consistency