On this page
Show commands for

The lab has its own AWS account. Running your work through it means the cost lands on the right grant instead of your personal card, and it gives you machines far larger than your laptop when a model needs them.

This guide gets you three things: credentials that let you talk to the lab’s AWS resources, Claude Code billed through Bedrock to the lab account, and SageMaker training jobs that run on cloud hardware with data in S3.

Set aside about 30 minutes.

Install the AWS CLI

The AWS command line tool is how you check credentials, move data in and out of S3, and inspect training jobs without opening a browser.

Windows

winget install Amazon.AWSCLI

Then close the terminal and open a new one — the installer edits your PATH, and an already-open window keeps the old one.

If winget is unavailable, download and run the MSI installer instead.

macOS

brew install awscli

If you do not have Homebrew, Lab computing setup installs it. Failing that, use Amazon’s installer:

curl "https://awscli.amazonaws.com/AWSCLIV2.pkg" -o "AWSCLIV2.pkg"
sudo installer -pkg AWSCLIV2.pkg -target /

Linux

curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
unzip awscliv2.zip
sudo ./aws/install

Do not use apt install awscli — Ubuntu ships version 1, which is a different and much older tool.

Check it worked:

aws --version

You should get something like aws-cli/2.x.x Python/3.x.x. The leading 2 matters.

Configure your credentials

Adam will send you a CSV file, named something like yourname_accessKeys.csv. Open it and you will find two values: an Access key ID starting with AKIA, and a Secret access key, which is a long random string.

Run:

aws configure

It asks four questions. Paste the two values from the CSV, then use these answers for the rest:

AWS Access Key ID [None]: AKIAIOSFODNN7EXAMPLE
AWS Secret Access Key [None]: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
Default region name [None]: us-east-1
Default output format [None]: json

The lab runs in us-east-1. Everything in this guide assumes it.

This writes your keys to ~/.aws/credentials and the region to ~/.aws/config. Every AWS tool you use from now on — the CLI, boto3, SageMaker, Claude Code — reads them from there automatically. You will not have to type them again.

Check that it worked

aws sts get-caller-identity
{
    "UserId": "AIDAI...",
    "Account": "YOUR_ACCOUNT_ID",
    "Arn": "arn:aws:iam::YOUR_ACCOUNT_ID:user/your-username"
}

If that errors, the keys were mistyped or mis-pasted — run aws configure again and take care not to include a trailing space.

Now check you can reach the lab’s storage:

aws s3 ls s3://YOUR_BUCKET_NAME/
                           PRE code/
                           PRE datasets/
                           PRE models/
                           PRE notebooks/
                           PRE outputs/

PRE means “prefix” — S3’s equivalent of a folder. Those five are the layout the lab uses, and the rest of this guide writes into them.

Bill Claude Code to the lab account

By default Claude Code bills to a personal Claude subscription. Pointed at Amazon Bedrock, it uses the lab’s AWS account instead, which is what you want for lab work.

You already installed Claude Code in Lab computing setup. It now has a wizard for this, so there is nothing to configure by hand:

claude

At the prompt, type:

/setup-bedrock

The wizard finds the AWS credentials you just configured, confirms your region, checks which Claude models the lab’s account can actually invoke, and writes the result into your Claude Code settings at ~/.claude/settings.json. Run it again any time to change region or model.

Check it took

Inside Claude Code, run /status. You are looking for three lines:

Version: 2.1.x
API provider: AWS Bedrock
AWS region: us-east-1

API provider: AWS Bedrock is the one that matters — that is the line that says your usage is being billed to the lab, not to you.

Per-project, rather than everywhere

The wizard configures Bedrock for all your Claude Code usage. If you also use Claude Code on personal projects and want those to stay on your own subscription, set it per project instead: create .claude/settings.json inside the project folder with

{
  "env": {
    "CLAUDE_CODE_USE_BEDROCK": "1",
    "AWS_REGION": "us-east-1"
  }
}

Claude Code merges settings from your home directory and the project, with the project winning. Commit this file — it is not secret, it holds no credentials, and it means everyone working on the repository bills the same way.

The full reference for all of this is Anthropic’s Claude Code on Amazon Bedrock page.

Set up a project for SageMaker

SageMaker work is an ordinary Python project. Start it the way Python projects, the lab way describes:

uv init sagemaker-experiments
cd sagemaker-experiments
uv add boto3 sagemaker pandas numpy scikit-learn matplotlib

That gives you:

  • boto3 — the AWS SDK for Python, the general-purpose way to talk to any AWS service
  • sagemaker — the SageMaker SDK, a friendlier layer for launching training jobs
  • pandas, numpy, scikit-learn, matplotlib — the usual analysis stack

Check the SageMaker SDK imports:

uv run python -c "import sagemaker; print(sagemaker.__version__)"

The configuration module

Every script in this guide needs the same three facts: which bucket, which IAM role, which region. Put them in one file, sagemaker_config.py, and import it everywhere:

"""Shared SageMaker configuration for Gormley Lab projects."""

import boto3
import sagemaker

# The lab's S3 bucket. Ask Adam for the real name.
BUCKET_NAME = "YOUR_BUCKET_NAME"

# The role SageMaker assumes when it runs your job. Ask Adam for the account ID.
SAGEMAKER_ROLE = "arn:aws:iam::YOUR_ACCOUNT_ID:role/GormleyLabSageMakerExecutionRole"

REGION = "us-east-1"


def get_sagemaker_session():
    """A SageMaker session bound to the lab's region."""
    return sagemaker.Session(boto_session=boto3.Session(region_name=REGION))


# Where things live in the bucket.
PATHS = {
    "datasets": f"s3://{BUCKET_NAME}/datasets/",
    "models": f"s3://{BUCKET_NAME}/models/",
    "code": f"s3://{BUCKET_NAME}/code/",
    "outputs": f"s3://{BUCKET_NAME}/outputs/",
}

# Instance types, cheapest first. See "Keeping costs down" below.
INSTANCE_TYPES = {
    "cpu_small": "ml.m5.large",       # debugging, small data
    "cpu_medium": "ml.m5.xlarge",     # medium data, no GPU needed
    "gpu_budget": "ml.g4dn.xlarge",   # T4 — small networks
    "gpu_standard": "ml.g5.xlarge",   # A10G — normal deep learning
    "gpu_large": "ml.g5.12xlarge",    # 4x A10G — large models
}

The IAM role is worth a sentence. Your own credentials let you submit a job. The role is what the training machine itself uses once it starts — that is how it reads your data out of S3 and writes the model back. Adam creates it; you only ever refer to it by that ARN.

How a SageMaker training job works

This is the part that confuses people first, so it is worth being explicit. Every job is built from two files.

The training script — say train.py — holds your actual machine learning code: load data, define a model, fit it, evaluate, save. This file runs on a remote machine. You never execute it yourself.

The launcher — say run_training.py — is the file you run locally. It does not train anything. It describes a job to SageMaker: which script to run, on what hardware, with which hyperparameters, and where to put the result.

run_training.py (local)

       │  tells SageMaker:
       │    entry_point='train.py'  ← which script to run
       │    instance_type           ← what hardware to use
       │    hyperparameters         ← become CLI args to train.py
       │    output_path             ← where to save the model in S3

train.py (remote, on a SageMaker instance)

       │  receives hyperparameters as command-line arguments
       │  trains the model
       │  saves the model where SageMaker tells it to

Model artifact uploaded to S3

Two mechanisms connect them, and both look strange until you have seen them once:

  • Hyperparameters arrive as command-line arguments. A launcher that sets hyperparameters={'n-estimators': 100} causes SageMaker to run python train.py --n-estimators 100 on the remote machine. That is why training scripts use argparse.
  • Paths arrive as environment variables. SageMaker sets SM_MODEL_DIR to the directory whose contents get uploaded to S3 when the job finishes, and SM_CHANNEL_TRAIN to wherever it put your input data. The training script reads them with os.environ.

The upshot is that train.py knows nothing about S3, or the cloud, or SageMaker. It reads a directory, trains, and writes a directory. You can run it on your laptop unchanged.

Where S3 fits in

S3 is the shared ground between your machine and the remote one. It has two jobs.

Input. Your data has to be somewhere the remote machine can reach, so it lives in S3. You point .fit() at an S3 path, and SageMaker copies that data onto the instance before your script starts, leaving the local path in SM_CHANNEL_TRAIN.

Output. When your script writes a model into SM_MODEL_DIR, SageMaker uploads everything in that directory to the output_path you gave it. That is how the trained model gets back somewhere you can reach it.

Your data (local)

       │  aws s3 cp data.csv s3://YOUR_BUCKET_NAME/datasets/

S3 bucket (input)      ← data lives here before training

       │  SageMaker copies it onto the remote instance

run_training.py (local)

       │  .fit({'train': 's3://...'})  ← where to get input data
       │  output_path='s3://...'       ← where to put the model

train.py (remote)

       │  reads the data from a local path (copied from S3)
       │  trains the model, saves it to SM_MODEL_DIR

S3 bucket (output)     ← trained model uploaded here automatically

       │  aws s3 cp s3://...model.tar.gz ./

Your machine (local)   ← download it and use it

Your first training job

A random forest on the iris dataset. Small, fast, and cheap — the point is the machinery, not the model.

The training script

Create train.py:

"""Train a random forest on iris. Runs on the remote SageMaker instance."""

import argparse
import os

import joblib
from sklearn.datasets import load_iris
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score, classification_report
from sklearn.model_selection import train_test_split


def train(args):
    print("Loading data...")
    # A built-in dataset, so nothing needs uploading to S3 for this example.
    # With your own data you would read from args.train instead — that is the
    # directory SageMaker copies your S3 input into.
    iris = load_iris()
    X, y = iris.data, iris.target

    X_train, X_test, y_train, y_test = train_test_split(
        X, y, test_size=0.2, random_state=42
    )
    print(f"Training set: {len(X_train)}   Test set: {len(X_test)}")

    print(f"Training a random forest with {args.n_estimators} trees...")
    model = RandomForestClassifier(
        n_estimators=args.n_estimators,
        max_depth=args.max_depth,
        random_state=42,
    )
    model.fit(X_train, y_train)

    train_accuracy = accuracy_score(y_train, model.predict(X_train))
    test_accuracy = accuracy_score(y_test, model.predict(X_test))
    print(f"\nTrain accuracy: {train_accuracy:.4f}")
    print(f"Test accuracy:  {test_accuracy:.4f}\n")
    print(classification_report(y_test, model.predict(X_test), target_names=iris.target_names))

    # Anything written here is uploaded to S3 when the job finishes.
    model_path = os.path.join(args.model_dir, "model.joblib")
    joblib.dump(model, model_path)
    print(f"Model saved to {model_path}")


if __name__ == "__main__":
    parser = argparse.ArgumentParser()

    # Hyperparameters — SageMaker passes these in from the launcher.
    parser.add_argument("--n-estimators", type=int, default=100)
    parser.add_argument("--max-depth", type=int, default=5)

    # Paths — SageMaker sets these environment variables on the instance.
    parser.add_argument("--model-dir", type=str, default=os.environ.get("SM_MODEL_DIR", "./model"))
    parser.add_argument("--train", type=str, default=os.environ.get("SM_CHANNEL_TRAIN", "./data"))

    args = parser.parse_args()
    os.makedirs(args.model_dir, exist_ok=True)
    train(args)

Note the defaults on the last two arguments. Because they fall back to local directories when the environment variables are absent, uv run python train.py works on your laptop too. Test there first — it is free.

The launcher

Create run_training.py:

"""Submit the iris training job to SageMaker. Run this locally."""

from datetime import datetime

from sagemaker.sklearn import SKLearn

import sagemaker_config as config


def main():
    timestamp = datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
    print(f"Submitting job: sklearn-iris-{timestamp}")
    print(f"Instance type: {config.INSTANCE_TYPES['cpu_small']}")

    estimator = SKLearn(
        entry_point="train.py",                   # the script that runs remotely
        role=config.SAGEMAKER_ROLE,
        instance_type=config.INSTANCE_TYPES["cpu_small"],
        instance_count=1,
        framework_version="1.2-1",                # scikit-learn container
        py_version="py3",
        sagemaker_session=config.get_sagemaker_session(),
        hyperparameters={                         # become --n-estimators 100
            "n-estimators": 100,
            "max-depth": 5,
        },
        output_path=config.PATHS["models"],       # where the model lands
        code_location=config.PATHS["code"],
        base_job_name="sklearn-iris",
        use_spot_instances=True,                  # much cheaper, see below
        max_wait=3600,                            # stop waiting after 1 hour
        max_run=1800,                             # kill the job at 30 minutes
    )

    print("\nStarting. This takes a few minutes — most of it is machine startup.")

    # No argument, because train.py generates its own data. With real data:
    #   estimator.fit({"train": "s3://YOUR_BUCKET_NAME/datasets/your_data.csv"}, wait=True)
    estimator.fit(wait=True)

    print(f"\nDone. Model artifact: {estimator.model_data}")
    print(f"Job name: {estimator.latest_training_job.name}")


if __name__ == "__main__":
    main()

Run it

From your project folder:

uv run python run_training.py

The training logs stream back to your terminal as the job runs:

Submitting job: sklearn-iris-2026-08-23-14-30-45
Instance type: ml.m5.large

Starting. This takes a few minutes — most of it is machine startup.
2026-08-23 14:31:12 Starting - Starting the training job...
2026-08-23 14:31:45 Starting - Preparing the instances for training...
2026-08-23 14:32:30 Downloading - Downloading input data...
2026-08-23 14:32:45 Training - Training image download completed. Training in progress...
Loading data...
Training set: 120   Test set: 30
Training a random forest with 100 trees...

Train accuracy: 1.0000
Test accuracy:  0.9667

2026-08-23 14:33:15 Uploading - Uploading generated training model
2026-08-23 14:33:30 Completed - Training job completed

Done. Model artifact: s3://YOUR_BUCKET_NAME/models/sklearn-iris-.../output/model.tar.gz

Most of that elapsed time is provisioning a machine, not training. That overhead is fixed, which is why very short jobs are better run on your laptop.

A PyTorch job on a GPU

Same two-file pattern, different framework, and a machine with a GPU attached.

The training script

Create train_pytorch.py:

"""Train a small feedforward network. Runs on the remote SageMaker instance."""

import argparse
import os

import torch
import torch.nn as nn
import torch.optim as optim
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from torch.utils.data import DataLoader, TensorDataset


class SimpleNN(nn.Module):
    def __init__(self, input_dim, hidden_dim, output_dim):
        super().__init__()
        self.network = nn.Sequential(
            nn.Linear(input_dim, hidden_dim),
            nn.ReLU(),
            nn.Dropout(0.2),
            nn.Linear(hidden_dim, hidden_dim),
            nn.ReLU(),
            nn.Dropout(0.2),
            nn.Linear(hidden_dim, output_dim),
        )

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


def run_epoch(model, loader, criterion, device, optimizer=None):
    """One pass over the data. Trains if given an optimizer, evaluates if not."""
    training = optimizer is not None
    model.train() if training else model.eval()

    total_loss, correct, total = 0.0, 0, 0
    with torch.set_grad_enabled(training):
        for data, target in loader:
            data, target = data.to(device), target.to(device)
            output = model(data)
            loss = criterion(output, target)

            if training:
                optimizer.zero_grad()
                loss.backward()
                optimizer.step()

            total_loss += loss.item()
            correct += output.argmax(1).eq(target).sum().item()
            total += target.size(0)

    return total_loss / len(loader), 100.0 * correct / total


def main(args):
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    print(f"Using device: {device}")

    # Synthetic data, so nothing needs uploading for this example.
    print("Generating synthetic dataset...")
    X, y = make_classification(
        n_samples=1000, n_features=20, n_informative=15,
        n_redundant=5, n_classes=3, random_state=42,
    )
    X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

    scaler = StandardScaler()
    X_train = scaler.fit_transform(X_train)
    X_test = scaler.transform(X_test)

    train_loader = DataLoader(
        TensorDataset(torch.FloatTensor(X_train), torch.LongTensor(y_train)),
        batch_size=args.batch_size, shuffle=True,
    )
    test_loader = DataLoader(
        TensorDataset(torch.FloatTensor(X_test), torch.LongTensor(y_test)),
        batch_size=args.batch_size,
    )

    model = SimpleNN(input_dim=20, hidden_dim=args.hidden_dim, output_dim=3).to(device)
    criterion = nn.CrossEntropyLoss()
    optimizer = optim.Adam(model.parameters(), lr=args.learning_rate)

    print(f"\nTraining for {args.epochs} epochs...")
    for epoch in range(args.epochs):
        train_loss, train_acc = run_epoch(model, train_loader, criterion, device, optimizer)
        test_loss, test_acc = run_epoch(model, test_loader, criterion, device)
        print(
            f"Epoch {epoch + 1}/{args.epochs}: "
            f"train loss {train_loss:.4f} acc {train_acc:.2f}% | "
            f"test loss {test_loss:.4f} acc {test_acc:.2f}%"
        )

    # Save the scaler alongside the weights — without it the model cannot be used
    # on new data, because it expects inputs scaled exactly this way.
    model_path = os.path.join(args.model_dir, "model.pth")
    torch.save(
        {
            "model_state_dict": model.state_dict(),
            "scaler_mean": scaler.mean_,
            "scaler_scale": scaler.scale_,
        },
        model_path,
    )
    print(f"\nModel saved to {model_path}")


if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("--batch-size", type=int, default=32)
    parser.add_argument("--epochs", type=int, default=10)
    parser.add_argument("--learning-rate", type=float, default=0.001)
    parser.add_argument("--hidden-dim", type=int, default=64)
    parser.add_argument("--model-dir", type=str, default=os.environ.get("SM_MODEL_DIR", "./model"))

    args = parser.parse_args()
    os.makedirs(args.model_dir, exist_ok=True)
    main(args)

The launcher

Create run_pytorch_training.py:

"""Submit the PyTorch training job to SageMaker. Run this locally."""

from datetime import datetime

from sagemaker.pytorch import PyTorch

import sagemaker_config as config


def main():
    timestamp = datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
    instance_type = config.INSTANCE_TYPES["gpu_budget"]

    print(f"Submitting job: pytorch-nn-{timestamp}")
    print(f"Instance type: {instance_type}")

    estimator = PyTorch(
        entry_point="train_pytorch.py",
        role=config.SAGEMAKER_ROLE,
        instance_type=instance_type,
        instance_count=1,
        framework_version="2.9",       # PyTorch container version
        py_version="py312",
        sagemaker_session=config.get_sagemaker_session(),
        hyperparameters={
            "batch-size": 32,
            "epochs": 20,
            "learning-rate": 0.001,
            "hidden-dim": 128,
        },
        output_path=config.PATHS["models"],
        code_location=config.PATHS["code"],
        base_job_name="pytorch-nn",
        use_spot_instances=True,
        max_wait=7200,
        max_run=3600,
    )

    estimator.fit(wait=True)
    print(f"\nDone. Model artifact: {estimator.model_data}")


if __name__ == "__main__":
    main()

Monitoring your jobs

Jobs keep running whether or not your terminal is open — .fit(wait=True) only controls whether you wait. Closing your laptop does not stop the job, and does not stop the billing.

From the command line:

# Recent jobs
aws sagemaker list-training-jobs --max-results 10

# Everything about one job, including why it failed
aws sagemaker describe-training-job --training-job-name sklearn-iris-2026-08-23-14-30-45

# Stop one
aws sagemaker stop-training-job --training-job-name sklearn-iris-2026-08-23-14-30-45

In the browser, go to the AWS Console and open SageMaker AITrainingTraining jobs. Each job shows status, duration, the instance it used, and full logs.

For a quicker read on recent work, check_jobs.py:

"""Summarise recent SageMaker training jobs."""

import boto3

sagemaker = boto3.client("sagemaker", region_name="us-east-1")

response = sagemaker.list_training_jobs(
    MaxResults=20, SortBy="CreationTime", SortOrder="Descending"
)

print("Recent training jobs")
print("-" * 72)

for job in response["TrainingJobSummaries"]:
    status = job["TrainingJobStatus"]

    if status in ("Completed", "Failed", "Stopped"):
        duration = str(job["TrainingEndTime"] - job["CreationTime"]).split(".")[0]
    else:
        duration = "in progress"

    print(f"\n{job['TrainingJobName']}")
    print(f"  status:   {status}")
    print(f"  created:  {job['CreationTime']:%Y-%m-%d %H:%M:%S}")
    print(f"  duration: {duration}")

    if status == "Failed":
        print(f"  reason:   {job.get('FailureReason', 'unknown')}")
uv run python check_jobs.py

Working with S3

From the command line:

# Upload one file
aws s3 cp my_data.csv s3://YOUR_BUCKET_NAME/datasets/my_data.csv

# Upload a folder
aws s3 cp ./local_data s3://YOUR_BUCKET_NAME/datasets/my_project/ --recursive

# Download a trained model
aws s3 cp s3://YOUR_BUCKET_NAME/models/my_model.tar.gz ./

# See what is there
aws s3 ls s3://YOUR_BUCKET_NAME/datasets/

aws s3 sync is worth knowing too — it copies only what has changed, which matters once a dataset is large:

aws s3 sync ./local_data s3://YOUR_BUCKET_NAME/datasets/my_project/

From Python, s3_utils.py:

"""Small helpers for moving files in and out of the lab bucket."""

from pathlib import Path

import boto3

import sagemaker_config as config

s3 = boto3.client("s3", region_name=config.REGION)


def upload_file(local_path, s3_key):
    print(f"Uploading {local_path} -> s3://{config.BUCKET_NAME}/{s3_key}")
    s3.upload_file(str(local_path), config.BUCKET_NAME, s3_key)
    return f"s3://{config.BUCKET_NAME}/{s3_key}"


def download_file(s3_key, local_path):
    print(f"Downloading s3://{config.BUCKET_NAME}/{s3_key} -> {local_path}")
    s3.download_file(config.BUCKET_NAME, s3_key, str(local_path))


def upload_directory(local_dir, s3_prefix):
    local_dir = Path(local_dir)
    for path in local_dir.rglob("*"):
        if path.is_file():
            upload_file(path, f"{s3_prefix}/{path.relative_to(local_dir)}")


def list_files(s3_prefix):
    """List objects under a prefix, handling buckets with more than 1000 keys."""
    keys = []
    paginator = s3.get_paginator("list_objects_v2")
    for page in paginator.paginate(Bucket=config.BUCKET_NAME, Prefix=s3_prefix):
        keys.extend(obj["Key"] for obj in page.get("Contents", []))

    print(f"{len(keys)} file(s) under s3://{config.BUCKET_NAME}/{s3_prefix}")
    for key in keys:
        print(f"  - {key}")
    return keys

Keeping costs down

SageMaker bills per second of instance time, from when the machine starts to when it stops. A job you forget about is a job you are paying for.

Pick the smallest machine that works

Cost scales steeply with the instance, roughly like this — ml.m5.large as the unit:

Use case Instance Relative cost When
Debugging ml.m5.large Getting the job to run at all
CPU training ml.m5.xlarge ~2× Medium data, no GPU needed
Budget GPU ml.g4dn.xlarge ~6× Small networks, T4
Standard GPU ml.g5.xlarge ~12× Normal deep learning, A10G
Large GPU ml.g5.12xlarge ~60× Big models, 4× A10G

Current per-hour rates are on the SageMaker AI pricing page — check there before committing to anything large, since these change.

The habits that actually save money

  • Debug on cpu_small first. Most failures are typos, missing imports, and wrong paths. Finding them on the cheapest machine costs cents; finding them on a GPU costs real money.
  • Debug on your laptop before that. The training scripts here run locally unchanged.
  • Use spot instances. use_spot_instances=True with a max_wait typically saves 70–90%. AWS can interrupt a spot job, but for training runs that is usually an acceptable trade.
  • Always set max_run. It is a hard ceiling on how long the job can go. Without it, a training loop that fails to converge bills until someone notices.
  • Clean up S3. Model artifacts accumulate. Delete the ones from failed experiments.

Track what you ran

Six months from now you will want to know which hyperparameters produced which result. A plain-text log is enough to start with — append a line per job:

"""Append a record of each submitted job to experiments.jsonl."""

import json
from datetime import datetime, timezone
from pathlib import Path

LOG = Path("experiments.jsonl")


def log_experiment(job_name, hyperparameters, notes=""):
    record = {
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "job_name": job_name,
        "hyperparameters": hyperparameters,
        "notes": notes,
    }
    with LOG.open("a") as f:
        f.write(json.dumps(record) + "\n")
    print(f"Logged {job_name}")

One JSON object per line, appended. It never rewrites the file, so a crash mid-write cannot destroy your history, and it stays readable with cat. Commit it — it is a record of what you did.

Security and what not to commit

The single rule: credentials never go in the repository. Everything else follows from it.

Your AWS keys live in ~/.aws/credentials, outside any project, which is exactly why aws configure is the right way to store them. Nothing in your code should ever contain a key. If you find yourself typing AKIA into a Python file, stop.

Put this in .gitignore at the start of the project, before there is anything to leak:

# Credentials — never commit
.env
*.pem
*.key
*_accessKeys.csv
credentials.csv

# Python
.venv/
__pycache__/
*.pyc
.ipynb_checkpoints/

# Data and artifacts — these belong in S3
data/
model/
*.tar.gz

Two more worth knowing: the IAM role in sagemaker_config.py is not a secret, so committing that file is fine. And the account ID and bucket name are not secrets either in the security sense — they are just not public, which is why this guide uses placeholders.

When something goes wrong

aws: command not found — the installer updated your PATH but your terminal was already open. Close every terminal window and open a new one. If it persists, check the install landed:

Windows

$env:PATH -split ';' | Select-String -Pattern 'AWS'

macOS

echo $PATH | tr ':' '\n' | grep -i aws
which aws

Linux

echo $PATH | tr ':' '\n' | grep -i aws
which aws

Unable to locate credentialsaws configure was never run, or was run as a different user. Run it again, then aws sts get-caller-identity to confirm.

Claude Code is not using Bedrock — run /status and look at the API provider line. If it does not say AWS Bedrock, run /setup-bedrock again. If you set it up per project, make sure you started claude from inside that project folder.

AccessDenied on anything — your IAM permissions do not cover what you tried to do. This is not something you can fix yourself; send Adam the exact error message and what you were doing.

ResourceLimitExceeded — the account has a cap on how many of a given instance type can run at once. Wait for your other jobs to finish, use a different instance type, or ask Adam to request a limit increase from AWS.

A job is stuck in Starting — usually normal. Spot instances wait for capacity, and machine provisioning takes several minutes even without that. If it is still there after 15 minutes, describe-training-job will say why.

ValidationException: Could not find model data — the job failed before saving anything. The real error is in the job’s logs:

aws sagemaker describe-training-job --training-job-name YOUR_JOB_NAME

Read the FailureReason field, then open the job in the SageMaker AI console for the full CloudWatch log.

ModuleNotFoundError in a training job — the remote container does not have the package. Container images include the framework and its usual companions, not your whole environment. Add a requirements.txt next to your training script and SageMaker will install it on the instance before running your code.

ModuleNotFoundError locally — you ran python instead of uv run python, so you were outside the project environment.

Getting help

Ask Adam about anything to do with access, permissions, billing, or credentials — those are all account-level and you cannot change them yourself. Ask the lab for the ML questions.

When you do ask, include what you were trying to do, the exact error text, and the job name if there is one. describe-training-job output is usually the whole answer.

Where to go next

Run both examples end to end before adapting anything — it is much easier to tell a broken script from a broken setup when you have seen the setup work.

Then: upload one of your own datasets, change train.py to read from SM_CHANNEL_TRAIN instead of generating data, and run that. Once that works you have the full loop, and everything after it is ordinary machine learning.

Worth exploring when you need them: automatic hyperparameter tuning, distributed training across several instances, deploying a model to an endpoint for inference, and SageMaker Pipelines for multi-step workflows.