This is part one of three. It covers everything you need to do real work with ClearML, not a teaser. By the end you can track an experiment without changing your training loop, log metrics and artifacts on purpose, compare twenty runs in a table, version a dataset, clone a run and re-launch it on a GPU box you never SSH into, and answer "what exactly produced this model?" with a task id. Mid-level and Senior take the same topics further; nothing here is thrown away.
Each section ends with a Try it task. Do them as you go. They take a few minutes each, and these ideas only stick once you have watched a scalar appear in the web UI while your script is still running.
What ClearML is, and the problem it solves
You trained a model on Tuesday. It scored 0.94. On Thursday you cannot reproduce it. The notebook has moved on, the learning rate in the cell is not the one you ran, the dataset folder has been "cleaned up", and the only record of 0.94 is a screenshot in Slack.
Discipline was never the problem. The tooling was: nothing was watching. Every fact about that run lived in your head, your terminal scrollback, and a filename.
ClearML watches. You add two lines to your script and it records the code, the git commit, the uncommitted diff, the installed packages, the command-line arguments, every metric you print to TensorBoard, the model file you saved, and the machine it ran on, all into a server you and your team can query.
ClearML's central trick is that a recorded run is executable. Other tools log an experiment so you can look at it. ClearML logs enough (repository, commit, diff, packages, arguments, environment) that a worker can rebuild the environment and run it again on a different machine. That single property is what turns a log into a workflow.
That gives you four things, and they are the reason ClearML exists rather than a spreadsheet and a naming convention:
Automatic experiment tracking
Two lines. Code, config, packages, metrics, and models get recorded without you writing logging calls.
Runs you can re-execute
Clone any past run, change one parameter, and send it to a queue. No SSH, no environment setup.
Versioned datasets
Immutable, content-addressed dataset versions with parents, so "which data?" has an id for an answer.
Pipelines and orchestration
Chain those runs into a graph, run each step on a different queue, and cache the steps that did not change.
The vocabulary is small, and getting it straight now saves confusion later:
| Term | What it is |
|---|---|
| Task | One recorded run: an experiment, a dataset version, a pipeline step. The core object |
| Project | A folder of tasks. Supports / for nesting, like research/nlp/ner |
| Server | The API server, web server, and file server that store and show everything |
| Agent | A worker process that pulls tasks off a queue and executes them |
| Queue | A named, ordered list of tasks waiting for an agent |
| Artifact | Any file or object you attach to a task: a dataframe, a plot, a checkpoint |
| Model | A first-class artifact with its own registry entry, tags, and lineage |
| Dataset | A special task that holds an immutable, versioned collection of files |
You need little to follow along: Python, a script that trains something small, and a free account on the hosted server. A tiny model on a tiny dataset is the best place to start, because a broken run costs you thirty seconds.
- Find your most recent training script. Write down, from memory, the exact learning rate, batch size, and dataset it last ran with.
- Now check the script and see whether you were right.
- Look for the model file it produced and try to name the git commit that trained it.
- Count how many folders on your disk match
*_v2*,*_final*, orruns/.
The architecture: four parts, three data stores
ClearML has more moving pieces than a single library, and knowing which piece does what makes every error message readable.
Task.init()Patches frameworks at import time, records code, diff, packages, args; streams metricsoutput_uri: use S3 for anything largeLearn the top two lanes first. Tracking needs no agent anywhere: install the SDK, run your script, get a full record. The bottom lane is the second half of the story, re-executing a recorded run elsewhere, and trying to learn both at once is why people find ClearML complicated.
| Part | What it does | Where it runs |
|---|---|---|
SDK (pip install clearml) |
Patches your frameworks, sends metadata and metrics, uploads files | Inside your training process |
| API server | Stores tasks, parameters, metrics, and queues. Everything talks to it | Port 8008 |
| Web server | The UI you compare experiments in. Talks only to the API server | Port 8080 |
| File server | Stores artifacts, models, and debug images when you have no S3 bucket | Port 8081 |
Agent (pip install clearml-agent) |
Pulls a task from a queue, rebuilds its environment, runs it | Any machine with the code's dependencies |
Behind the API server sit three data stores, and knowing which is which explains later performance advice: MongoDB holds task metadata, Elasticsearch holds metrics and console logs, and Redis holds ephemeral state. You do not touch them directly at this level, but "my scalars are slow" and "my task list is slow" are two different problems for that reason.
You have two options for the server, and for learning there is only one sensible answer:
Hosted (app.clear.ml)
- Free tier, no setup at all
- Signup, copy credentials, done in two minutes
- Right choice for learning and for small teams
Self-hosted
- Docker Compose or Kubernetes, plus Mongo, Elasticsearch, Redis
- You own backups, upgrades, storage, and access control
- Necessary when data cannot leave your network; Senior covers it
- Sign up at the hosted server and open the Workers & Queues page. It is empty. Nothing needs an agent yet.
- Open Settings → Workspace and find the "Create new credentials" button. Do not click it yet.
- Sketch the four parts above from memory and label which one your training script talks to.
Install and connect: credentials and config
Two commands, and the second one is interactive.
pip install clearml
clearml-init
clearml-init asks you to paste a credentials block, which you copy from the web UI under Settings → Workspace → Create new credentials. It looks like this, and it is the only setup step in the whole guide:
api {
web_server: https://app.clear.ml
api_server: https://api.clear.ml
files_server: https://files.clear.ml
credentials {
"access_key" = "ABC123..."
"secret_key" = "xyz789..."
}
}
That gets written to ~/clearml.conf, a large, heavily commented file worth skimming once. The four lines that matter today:
| Setting | Means |
|---|---|
api.api_server |
Where metadata goes |
api.files_server |
Where artifacts and models go by default |
api.credentials |
Your access key and secret key pair |
sdk.development.default_output_uri |
Optional: send models and artifacts to S3 instead of the file server |
Every one of those can be overridden by an environment variable, which is how you configure CI and containers where there is no config file to edit:
export CLEARML_API_HOST=https://api.clear.ml
export CLEARML_WEB_HOST=https://app.clear.ml
export CLEARML_FILES_HOST=https://files.clear.ml
export CLEARML_API_ACCESS_KEY=ABC123...
export CLEARML_API_SECRET_KEY=xyz789...
clearml.conf, never paste it into a notebook you will share, and use environment variables in CI so there is no file to leak. Senior covers scoped credentials and service accounts properly.
Confirm it works before writing any code:
clearml-init # re-run any time to reconfigure
python -c "from clearml import Task; print(Task.get_projects()[:3])"
- Run
pip install clearmlthenclearml-initand paste your credentials. - Open
~/clearml.confand findfiles_server, then findsdk.developmentand skim the comments. - Run the one-line Python check above and confirm you get a list back rather than an authentication error.
- Add
clearml.confto your global gitignore right now:echo "clearml.conf" >> ~/.gitignore_global.
Your first tracked task, in two lines
This is the whole beginner story in two lines. Start from a script with no ClearML in it:
import numpy as np
from sklearn.datasets import load_iris
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split
n_estimators = 100
max_depth = 4
X, y = load_iris(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
model = RandomForestClassifier(n_estimators=n_estimators, max_depth=max_depth)
model.fit(X_train, y_train)
print("accuracy", model.score(X_test, y_test))
Here it is tracked:
from clearml import Task
task = Task.init(project_name="iris-demo", task_name="random forest baseline")
import numpy as np
from sklearn.datasets import load_iris
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split
params = task.connect({"n_estimators": 100, "max_depth": 4})
X, y = load_iris(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
model = RandomForestClassifier(n_estimators=params["n_estimators"], max_depth=params["max_depth"])
model.fit(X_train, y_train)
accuracy = model.score(X_test, y_test)
task.get_logger().report_single_value("accuracy", accuracy)
print("accuracy", accuracy)
Run it. The first thing it prints is the link that matters:
ClearML Task: created new task id=8f2c4b19e0a7d3f1c6b8a2e4d7091f3b
ClearML results page: https://app.clear.ml/projects/.../experiments/8f2c4b19.../output/log
Open that link while the script is still running and you are watching the console output stream into the UI. Four things happened without you asking:
- A task was createdWith an id, a project, a name, a status of
running, and a start time. - Your environment was recordedGit remote, branch, commit, the diff of your uncommitted changes, and the installed packages the script imported.
- Your stdout and stderr were capturedStreamed to the server, so the console tab is a live log you can read from your phone.
- Your parameters were registeredBecause you passed that dict through
task.connect, they are now editable in the UI, which is what makes the run re-launchable.
Task.init goes as early as possible
ClearML works by patching frameworks at import time. If Task.init runs after import torch, the automatic capture of TensorBoard scalars and model checkpoints may silently not happen. Put it at the top of the entry-point script, before the heavy imports. This is the single most common reason someone says "it is not logging my metrics".
- Take the script above, run it, and open the results link while it is still running.
- In the UI, visit each tab in turn: Execution, Configuration, Console, Scalars, Artifacts.
- Under Execution, find the git commit and the "uncommitted changes" section. Make an unrelated edit to the file, rerun, and look again.
- Under Configuration → Hyperparameters, confirm
n_estimatorsandmax_depthare listed with their values.
What gets captured automatically, and what does not
The word ClearML uses for this is "automagic", and knowing its exact boundaries stops you from either duplicating work or expecting magic that is not there.
Captured with no code from you, as long as Task.init ran first:
| Category | What lands in the task |
|---|---|
| Code | Git remote, branch, commit, uncommitted diff, entry-point script and working directory |
| Environment | Python version, and the packages your script imported with their exact versions |
| Arguments | Every argparse flag, plus click, fire, and hydra configs |
| Metrics | Anything written to TensorBoard, TensorBoardX, or matplotlib |
| Models | Every checkpoint saved by PyTorch, TensorFlow, Keras, scikit-learn via joblib, XGBoost, LightGBM |
| Console | stdout and stderr, streamed |
| Machine | Hostname, CPU, GPU model, memory, and utilisation over time |
That means an existing TensorBoard-instrumented project needs no logging changes at all:
from clearml import Task
task = Task.init(project_name="vision", task_name="resnet baseline")
from torch.utils.tensorboard import SummaryWriter
writer = SummaryWriter()
for epoch in range(epochs):
loss = train_one_epoch()
writer.add_scalar("loss/train", loss, epoch) # appears in ClearML Scalars
torch.save(model.state_dict(), "checkpoint.pt") # appears in ClearML Models
What is not captured, and this list matters:
| Not captured | Why | What to do |
|---|---|---|
Values you print() as metrics |
A print is text, not a number with an iteration | report_scalar |
| Data read from a path outside version control | ClearML cannot know what was in it | Use a ClearML Dataset |
| Conda/apt/system libraries | Only Python imports are detected | Use docker mode on the agent |
| Environment variables | Deliberately excluded, because they hold secrets | Pass real config through parameters |
| Random seeds you never set | There is nothing to record | Set and connect them explicitly |
You can turn parts of the magic off when it gets in the way:
task = Task.init(
project_name="vision",
task_name="resnet baseline",
auto_connect_frameworks={"matplotlib": False, "pytorch": True},
auto_connect_arg_parser=False, # do not hoover up argparse
output_uri="s3://my-bucket/clearml", # send models and artifacts to S3
)
- Add
Task.initto a project of yours that already uses TensorBoard, and change nothing else. - Run it and confirm the scalars appear in ClearML's Scalars tab with the same titles and series.
- Add an
argparseflag, pass a value on the command line, and find it under Configuration → Hyperparameters → Args. - Now move
Task.initto the bottom of the imports, rerun, and compare what was captured.
Logging on purpose: the Logger
Automatic capture gets you a long way. The Logger is for everything you want recorded deliberately, and its API is small enough to memorise.
logger = task.get_logger()
# A number that changes over training → a curve
logger.report_scalar(title="loss", series="train", value=0.412, iteration=epoch)
logger.report_scalar(title="loss", series="val", value=0.503, iteration=epoch)
# A number that happens once → a table row, comparable across tasks
logger.report_single_value(name="test_accuracy", value=0.941)
# A table
logger.report_table(title="confusion", series="test", iteration=0, table_plot=df)
# An image
logger.report_image(title="samples", series="epoch-3", iteration=3, local_path="grid.png")
# Free text into the task log
logger.report_text("Trained on dataset version 3fa1b2c")
# Any matplotlib figure
logger.report_matplotlib_figure(title="roc", series="test", iteration=0, figure=fig)
The title / series distinction is the one people get wrong, and it decides what your Scalars tab looks like:
| Means | Result | |
|---|---|---|
title |
The name of the plot | One chart per title |
series |
A line within that plot | Multiple lines on one chart |
So title="loss" with series="train" and series="val" gives you one chart with two lines, which is what you want. Using title="train_loss" and title="val_loss" gives you two separate charts you cannot visually compare, which is not.
report_single_value is what shows up in comparison tables
A scalar is a curve; a single value is a fact about the run. Final test accuracy, total training time, and model size belong in report_single_value, because those are the columns you will sort twenty experiments by later. Report both: the curve for diagnosis, the single value for comparison.
A realistic training loop, fully instrumented:
from clearml import Task
task = Task.init(project_name="vision", task_name="resnet18 baseline")
logger = task.get_logger()
params = task.connect({"lr": 3e-4, "batch_size": 64, "epochs": 20, "seed": 42})
set_seed(params["seed"])
for epoch in range(params["epochs"]):
train_loss = train_one_epoch(model, train_loader, lr=params["lr"])
val_loss, val_acc = evaluate(model, val_loader)
logger.report_scalar("loss", "train", train_loss, epoch)
logger.report_scalar("loss", "val", val_loss, epoch)
logger.report_scalar("accuracy", "val", val_acc, epoch)
logger.report_scalar("lr", "current", scheduler.get_last_lr()[0], epoch)
test_acc = evaluate(model, test_loader)[1]
logger.report_single_value("test_accuracy", test_acc)
logger.report_single_value("params_millions", count_params(model) / 1e6)
- Instrument a loop with
report_scalarusing onetitleand twoseries. Watch the two lines land on one chart. - Now split them into two titles and see the difference in the Scalars tab. Put them back.
- Add two
report_single_valuecalls and find them under the Scalars tab's single-value section. - Report a matplotlib figure and confirm it appears under Plots, interactive rather than as a flat image.
Hyperparameters: the editable surface of a task
Parameters are not just a record. In ClearML they are the editable surface of a task, the thing you change when you clone a run and re-launch it. That is why how you register them matters more than it looks.
Three mechanisms, for three shapes of configuration:
# 1. A dict of scalars — the common case
params = task.connect({"lr": 3e-4, "batch_size": 64, "epochs": 20})
print(params["lr"]) # read from the returned dict, always
# 2. argparse — captured automatically, no call needed
parser = argparse.ArgumentParser()
parser.add_argument("--lr", type=float, default=3e-4)
args = parser.parse_args() # already registered under "Args"
# 3. A nested config object or file — too big for a parameter list
config = task.connect_configuration(
configuration="configs/model.yaml",
name="model config",
)
| Mechanism | Best for | Appears as |
|---|---|---|
task.connect(dict) |
Hyperparameters | An editable key/value section |
argparse |
Command-line scripts | The Args section, automatically |
task.connect_configuration |
YAML/JSON files, nested structures | An editable text blob |
The critical detail is that connect is bidirectional. When your code runs normally, connect uploads the dict to the server. When an agent runs a clone of that task, connect reads the values back from the server and overwrites your defaults. Same line of code, opposite direction.
params = task.connect({"lr": 3e-4}) then use(params["lr"]) is correct. Calling task.connect(cfg) and then reading your original cfg variable works by luck for a plain dict (it is mutated in place) and silently fails for other types. Read from what connect gave you back and the remote-override behaviour always works.
# Locally: lr is 3e-4, uploaded to the server.
# In the UI you clone the task, edit lr to 1e-3, and enqueue.
# On the agent: connect() returns {"lr": 1e-3} — your 3e-4 default is ignored.
params = task.connect({"lr": 3e-4})
train(lr=params["lr"]) # runs with 1e-3, no code change
- Connect a dict of three hyperparameters and run the script.
- In the UI, open Configuration → Hyperparameters and confirm all three are there and editable.
- Add a YAML file with
connect_configurationand find it under Configuration → Configuration Objects. - Deliberately read from your original literal instead of the returned dict, and note that nothing breaks yet. The failure only appears once an agent runs a clone.
Artifacts: files and objects on a task
An artifact is any file or Python object you attach to a task. Use them for the things that are neither metrics nor the final model: a preprocessed dataframe, an evaluation report, a vocabulary, a set of predictions.
# A pandas DataFrame — stored as a compressed CSV, previewable in the UI
task.upload_artifact("test predictions", artifact_object=predictions_df)
# A local file or folder
task.upload_artifact("report", artifact_object="outputs/report.html")
task.upload_artifact("plots", artifact_object="outputs/plots/") # zipped
# Any picklable object
task.upload_artifact("vocab", artifact_object=vocab_dict)
# A numpy array
task.upload_artifact("embeddings", artifact_object=embeddings)
Reading them back from another script is the part that makes them useful:
from clearml import Task
producer = Task.get_task(project_name="vision", task_name="resnet18 baseline")
df = producer.artifacts["test predictions"].get() # deserialised object
path = producer.artifacts["report"].get_local_copy() # downloaded file path
| Object you pass | How it is stored | .get() returns |
|---|---|---|
pandas.DataFrame |
Compressed CSV, with a UI preview | The DataFrame |
numpy.ndarray |
.npz |
The array |
dict / list |
JSON when possible, else pickle | The object |
| A file path | Uploaded as-is | Use get_local_copy() |
| A folder path | Zipped | Use get_local_copy() |
| Anything else | Pickle | The object |
output_uri, everything you upload lands on the ClearML file server. That is fine for reports and dataframes and wrong for 40 GB of checkpoints. The hosted tier has a quota, and a self-hosted file server is a disk you have to manage. Point output_uri at S3 for anything large.
task = Task.init(project_name="vision", task_name="run", output_uri="s3://my-bucket/clearml")
# or, for every task on this machine, in clearml.conf:
# sdk.development.default_output_uri: "s3://my-bucket/clearml"
- Upload a small DataFrame as an artifact and open it in the UI's Artifacts tab, then note the inline preview.
- Upload a folder and confirm it arrives as a single zip.
- Write a second script that fetches the task by name and calls
.get()on the dataframe artifact. - Check the artifact's URL in the UI and see which server it landed on.
Models and the registry: weights with provenance
A model is an artifact with privileges: its own registry entry, its own tags, and a recorded link back to the task that produced it. Most of the time you get one for free.
# Automatic: ClearML intercepts the framework's save call
torch.save(model.state_dict(), "model.pt")
joblib.dump(sklearn_model, "model.pkl")
# → both appear in the task's Models tab and in the project's Models list
When you want control, register explicitly:
from clearml import OutputModel
output_model = OutputModel(task=task, name="resnet18-cls", framework="PyTorch")
output_model.update_weights(weights_filename="model.pt")
output_model.update_design(config_dict={"arch": "resnet18", "classes": 10})
output_model.tags = ["baseline", "candidate"]
Loading one back, which is the half that makes a registry a registry:
from clearml import InputModel
model = InputModel(model_id="a1b2c3d4e5f6") # by id
model = InputModel(project="vision", name="resnet18-cls", # or by query
tags=["production"], only_published=True)
local_weights = model.get_local_copy() # downloads and caches
The lifecycle is deliberately simple at this level:
candidate, staging, or production. Tags are how humans and scripts find it.InputModel queries by project, name, and tag rather than by a file path.InputModel(project="vision", name="resnet18-cls", tags=["production"]) is the whole point of a registry: your serving code stops containing a filename and starts containing an intent. Promoting a new model then means moving a tag rather than editing and redeploying code.
- Save a model with your framework's normal save call and find it in the project's Models list without writing any ClearML model code.
- Open the model and follow the link back to the task that created it. Confirm you can reach the exact commit from there.
- Tag it
candidate, then write a script that loads it withInputModel(..., tags=["candidate"])and prints the local path. - Publish it, then try to change its tags or weights.
Datasets: immutable, incremental versions
A tracked experiment that reads /data/train.csv is only half-reproducible. ClearML Dataset closes that gap: an immutable, versioned, content-addressed collection of files that is itself a task, so it has an id, a project, tags, and lineage.
From the command line:
clearml-data create --project datasets --name iris --version 1.0.0
clearml-data add --files ./data
clearml-data close # uploads and makes the version immutable
Or from Python, which is what you will use inside a pipeline:
from clearml import Dataset
ds = Dataset.create(dataset_project="datasets", dataset_name="iris", dataset_version="1.0.0")
ds.add_files("./data")
ds.upload()
ds.finalize()
print(ds.id)
Consuming it is one line, and this is the line that makes an experiment reproducible:
from clearml import Dataset
path = Dataset.get(dataset_project="datasets", dataset_name="iris", alias="training data").get_local_copy()
df = pd.read_csv(f"{path}/train.csv")
get_local_copy() downloads once and caches. Call it again on the same machine and it returns the cached path immediately. The alias argument is important: it records the dataset id into the consuming task's parameters, so the experiment's Configuration tab names the exact data version it used.
Versions are incremental rather than full copies, which is what makes them cheap:
child = Dataset.create(
dataset_project="datasets",
dataset_name="iris",
dataset_version="1.1.0",
parent_datasets=[ds.id], # inherit everything from 1.0.0
)
child.add_files("./new-batch") # only the delta is uploaded
child.finalize()
| Operation | Command |
|---|---|
| Create a version | Dataset.create(...) |
| Add files | ds.add_files(path) |
| Remove files in a child | ds.remove_files("old/*.csv") |
| Upload and seal | ds.upload() then ds.finalize() |
| Fetch, cached | Dataset.get(...).get_local_copy() |
| Fetch, writable | Dataset.get(...).get_mutable_local_copy(target) |
| Inspect | ds.list_files(), ds.get_logger() |
| Compare versions | Dataset.squash, or the UI's version tree |
finalize(), that version is sealed. To change anything you create a child version. This is deliberate: an experiment that says "trained on iris 1.1.0" is worthless if 1.1.0 can be edited afterwards. If you find yourself wanting to mutate a finalized version, what you want is a new child.
get_local_copy(), not get_mutable_local_copy(), in training
The cached read-only copy is shared between every task on the machine, so ten runs against the same dataset download it once. A mutable copy is a fresh full extraction every time, which is what you want for a preprocessing step that edits files in place and nothing else.
- Create a dataset from a folder with two small CSVs and finalize it.
- Create a child version that adds one more file, and confirm in the UI that only the new file was uploaded.
- Fetch it in a training script with an
alias, then check the training task's Configuration tab for the recorded dataset id. - Run the same fetch twice and compare the wall-clock time.
Comparing experiments: table, chart, and code
This is where the tracking pays off, and it is almost entirely a UI skill. Run your script three times with different learning rates before reading on, so you have something to compare.
In the experiments table:
| Action | How |
|---|---|
| Add a metric as a column | The gear icon → pick from metrics and hyperparameters |
| Sort by a metric | Click the column header |
| Filter | The funnel on any column; or the search box for names and tags |
| Compare | Tick two or more rows → Compare |
| Find the differences only | In compare view, toggle "hide identical values" |
The compare view has three tabs, and each answers a different question:
What you use it for
- Details: a diff of hyperparameters, packages, and even the code
- Scalars: all runs' curves overlaid on one chart
- Plots: side-by-side confusion matrices and custom figures
What it will not do for you
- Compare metrics you only
print()ed - Compare parameters you never
connected - Diff data you read from an unversioned path
The same queries work from Python, which is how you build a report or a promotion gate:
from clearml import Task
tasks = Task.get_tasks(
project_name="vision",
task_filter={"status": ["completed"], "order_by": ["-last_update"]},
)
for t in tasks[:10]:
metrics = t.get_last_scalar_metrics()
print(t.name, metrics.get("accuracy", {}).get("val", {}).get("last"))
best = max(tasks, key=lambda t: t.get_last_scalar_metrics()
.get("accuracy", {}).get("val", {}).get("last", 0))
print("best:", best.id, best.name)
baseline, ablation, broken, paper-v2, candidate. These are what make a table of four hundred runs navigable a month later. Add them in code with task.add_tags([...]) so they are never forgotten.
- Run one script three times with different learning rates, tagging each with
task.add_tags(["lr-sweep"]). - Add the learning rate and your accuracy single-value as columns, then sort by accuracy.
- Select all three, hit Compare, and use "hide identical values" in the Details tab.
- Reproduce the same ranking from Python with
Task.get_tasksandget_last_scalar_metrics.
Agents and queues: running a task elsewhere
Everything so far worked with no worker anywhere. The second half of ClearML takes a recorded run and executes it somewhere else.
An agent is a process that watches a queue. When a task is enqueued, the agent claims it, rebuilds its environment from the recorded packages, clones the recorded git commit, applies the recorded diff, and runs the recorded entry point.
pip install clearml-agent
clearml-agent init # same credentials flow as clearml-init
# Run a worker that watches the "default" queue
clearml-agent daemon --queue default
# On a GPU box: one worker per GPU
clearml-agent daemon --queue gpu --gpus 0
clearml-agent daemon --queue gpu --gpus 1
# In Docker mode — the environment is a container, not a virtualenv
clearml-agent daemon --queue gpu --docker nvidia/cuda:12.1.0-runtime-ubuntu22.04
The everyday loop, and it is this short:
- Right-click a completed task → CloneYou get an identical task in
draftstatus. The original is untouched; a completed task is read-only. - Edit whatever you wantHyperparameters, the docker image, the git branch, even the entry-point arguments. All editable while in draft.
- Enqueue it, choosing a queueThe task moves to
queued. If no agent watches that queue it waits, which is a normal state and not an error. - The agent picks it upCreates a virtualenv (or container), installs the recorded packages, clones the commit, applies the diff, runs the script.
- Watch it in the Console tabIdentical experience to a local run, on hardware you never logged into.
The mechanism that makes this smooth in development is execute_remotely:
from clearml import Task
task = Task.init(project_name="vision", task_name="resnet18")
params = task.connect({"lr": 3e-4, "epochs": 50})
# Everything above this line runs locally: the task is created and configured.
# Then the local process exits and the task is enqueued for an agent.
task.execute_remotely(queue_name="gpu")
# Nothing below here runs locally — only on the agent.
train(**params)
Run that on your laptop and it takes two seconds: it registers the task, uploads the config, enqueues it, and quits. The fifty-epoch training happens on the GPU box. Comment out one line and the identical script runs locally for debugging. That is the pattern to learn.
| Task status | Means |
|---|---|
draft |
Created or cloned, fully editable, not running |
queued |
Waiting for an agent on some queue |
in_progress |
An agent (or your laptop) is running it |
completed |
Finished with exit code 0 |
failed |
Non-zero exit; the console log holds the traceback |
aborted |
Stopped by a user or by a task.mark_stopped() |
published |
Read-only and protected from cloning-over |
- Start an agent on your own laptop:
clearml-agent daemon --queue default. It counts as a worker. - Clone one of your completed tasks in the UI, change one hyperparameter, and enqueue it to
default. - Watch the agent's terminal build a virtualenv, then watch the Console tab in the UI.
- Add
task.execute_remotely(queue_name="default")to your script and run it. Note how fast the local process exits. - Now enqueue a task whose commit you have not pushed, and read the failure.
Your first pipeline: tasks that create tasks
Once runs are re-executable, chaining them is a small step. A pipeline is a task that creates and monitors other tasks.
The decorator style is the clearest starting point:
from clearml import PipelineDecorator
@PipelineDecorator.component(return_values=["data_path"], cache=True, execution_queue="cpu")
def prepare(dataset_name: str):
from clearml import Dataset
return Dataset.get(dataset_project="datasets", dataset_name=dataset_name).get_local_copy()
@PipelineDecorator.component(return_values=["model_path", "accuracy"], execution_queue="gpu")
def train(data_path: str, lr: float, epochs: int):
# ordinary training code
return "model.pt", 0.941
@PipelineDecorator.component(return_values=["report"], execution_queue="cpu")
def evaluate(model_path: str, data_path: str):
return {"auc": 0.97}
@PipelineDecorator.pipeline(name="train-and-eval", project="vision", version="1.0.0")
def main(dataset_name="iris", lr=3e-4, epochs=20):
data_path = prepare(dataset_name)
model_path, accuracy = train(data_path, lr, epochs)
if accuracy > 0.9:
report = evaluate(model_path, data_path)
print(report)
if __name__ == "__main__":
PipelineDecorator.run_locally() # remove this line to run on agents
main()
Three things in there matter. Each component becomes its own task with its own console log, metrics, and artifacts. execution_queue is per component, so the CPU preprocessing and the GPU training run on different hardware without you orchestrating anything. The graph is inferred from the data flow: train depends on prepare because it consumes its return value, not because you declared an edge.
| Style | Use when |
|---|---|
PipelineDecorator |
The whole pipeline is Python you control. Clearest to read |
PipelineController with add_function_step |
You want explicit steps and parameters |
PipelineController with add_step |
Steps are existing tasks you clone by id or name |
The last one is the one that shows what ClearML is doing:
from clearml import PipelineController
pipe = PipelineController(name="nightly", project="vision", version="1.0.0")
pipe.add_parameter("lr", 3e-4)
pipe.add_step(name="prepare", base_task_project="vision", base_task_name="prepare data")
pipe.add_step(
name="train",
parents=["prepare"],
base_task_project="vision",
base_task_name="resnet18 baseline",
parameter_override={"General/lr": "${pipeline.lr}"},
)
pipe.start(queue="services")
A pipeline step here is literally "clone this existing task, override these parameters, run it". Nothing new is invented. It is the clone-and-enqueue loop from the previous section, expressed as a graph.
run_locally() first, always
Debugging a distributed pipeline is unpleasant. PipelineDecorator.run_locally() runs every component in your own process, sequentially, so a traceback is a normal traceback. Get it green locally, then delete that one line to fan it out across queues.
- Write a three-component pipeline where the first returns a number, the second doubles it, and the third prints it. Run it with
run_locally(). - Remove
run_locally()and run it against your local agent. Watch three separate tasks appear. - Open the pipeline task's Results → Pipeline tab and read the DAG it drew.
- Rerun the pipeline unchanged and watch the
cache=Truecomponent get skipped.
Reading a failed task, in order
Debugging ClearML has an order, and following it beats guessing.
- Read the Console tab firstYour script's stdout and stderr are there in full. Most failures are ordinary Python tracebacks and you can stop here.
- Then the Execution tabConfirm the git commit, the branch, the entry point, and the working directory are what you expected. A wrong commit explains a lot.
- Then the installed packages listThe agent installs exactly this list. A missing system library or a package your script imports lazily shows up as an
ImportErrorhere. - Then ConfigurationAn agent-run clone uses the server's parameter values, not your defaults. A surprising value here is usually the whole bug.
- Check the workerWorkers & Queues shows whether an agent is even watching that queue. A task sitting in
queuedforever is almost always no agent, or the wrong queue name. - Reproduce locally with the same config
Task.get_task(task_id=...), read its parameters, and run the script with them by hand.
Five failures cover most of what you will hit at this level:
| Symptom | Cause | Fix |
|---|---|---|
| No metrics appear | Task.init ran after the framework import |
Move it to the top of the entry point |
Task stuck in queued |
No agent on that queue | Start an agent, or check the queue name |
| Agent fails with a git error | Your commit was never pushed | Push the branch, then enqueue |
ImportError on the agent only |
Package imported lazily, so never detected | Add it to the task's package list or a requirements.txt |
| Parameter change had no effect | Code reads the literal, not connect's return |
Read from the returned object |
from clearml import Task
t = Task.get_task(task_id="8f2c4b19e0a7d3f1c6b8a2e4d7091f3b")
print(t.status, t.get_last_iteration())
print(t.get_parameters()) # what it actually ran with
print(t.get_last_scalar_metrics()) # what it produced
print(t.data.script.diff[:500]) # the uncommitted patch it applied
t.mark_stopped() # abort a stuck run
- Move
Task.initbelow your framework imports and confirm the scalars go missing. - Enqueue a task to a queue name that does not exist and watch it sit in
queued. - Import a package inside a function only, then run the task on an agent and read the
ImportError. - For each one, get to the diagnosis using only the UI tabs in the order above.
Putting it all together
Everything above in one project. Nothing here is new. Read it as a whole and you should be able to justify every line.
.
├── .gitignore # includes clearml.conf
├── requirements.txt
├── configs/
│ └── model.yaml # connected as a configuration object
├── src/
│ ├── make_dataset.py # creates a ClearML Dataset version
│ ├── train.py # the tracked experiment
│ ├── evaluate.py # loads a model by tag, reports metrics
│ └── pipeline.py # chains the three
└── README.md # the three commands to reproduce
from clearml import Dataset
ds = Dataset.create(
dataset_project="datasets",
dataset_name="iris",
dataset_version="1.0.0",
)
ds.add_files("./data")
ds.upload()
ds.finalize()
print("dataset id:", ds.id)
from clearml import Task # 1. import first
task = Task.init( # 2. init before heavy imports
project_name="vision",
task_name="resnet18 baseline",
output_uri="s3://my-bucket/clearml", # 3. artifacts to object storage
)
task.add_tags(["baseline"]) # 4. taggable from day one
import torch
from clearml import Dataset, OutputModel
params = task.connect({ # 5. editable on a clone
"lr": 3e-4,
"batch_size": 64,
"epochs": 20,
"seed": 42,
})
cfg = task.connect_configuration("configs/model.yaml", name="model config")
# 6. remote execution is one line; comment it out to debug locally
task.execute_remotely(queue_name="gpu")
set_seed(params["seed"])
data = Dataset.get( # 7. data version recorded via alias
dataset_project="datasets",
dataset_name="iris",
alias="training data",
).get_local_copy()
logger = task.get_logger()
for epoch in range(params["epochs"]):
train_loss = train_one_epoch(data, lr=params["lr"])
val_loss, val_acc = validate(data)
logger.report_scalar("loss", "train", train_loss, epoch) # 8. curves
logger.report_scalar("loss", "val", val_loss, epoch)
logger.report_scalar("accuracy", "val", val_acc, epoch)
test_acc = test(data)
logger.report_single_value("test_accuracy", test_acc) # 9. comparable
task.upload_artifact("predictions", predictions_df) # 10. artifact
model = OutputModel(task=task, name="resnet18-cls", framework="PyTorch")
model.update_weights(weights_filename="model.pt") # 11. registry
model.tags = ["candidate"]
from clearml import PipelineController
pipe = PipelineController(name="nightly", project="vision", version="1.0.0")
pipe.add_parameter("lr", 3e-4)
pipe.add_step(name="prepare", base_task_project="vision", base_task_name="make dataset")
pipe.add_step(
name="train",
parents=["prepare"],
base_task_project="vision",
base_task_name="resnet18 baseline",
parameter_override={"General/lr": "${pipeline.lr}"},
)
pipe.add_step(name="evaluate", parents=["train"],
base_task_project="vision", base_task_name="evaluate")
pipe.start(queue="services") # start_locally() while developing
# One-time setup
pip install clearml clearml-agent
clearml-init
# Everyday loop
python src/train.py # tracked; enqueued to gpu by execute_remotely
# → compare in the UI, clone the winner, edit lr, enqueue again
# A worker, wherever the hardware is
clearml-agent daemon --queue gpu --docker nvidia/cuda:12.1.0-runtime-ubuntu22.04
Eleven decisions in there are the whole lesson of this page:
| Decision | Section |
|---|---|
Task.init before framework imports |
Your first tracked task |
output_uri to S3, not the file server |
Artifacts |
| Tags added in code, not by hand | Comparing experiments |
Hyperparameters through connect, read from the return |
Hyperparameters and configuration |
A YAML config through connect_configuration |
Hyperparameters and configuration |
execute_remotely as the one line that moves the run |
Agents and queues |
Data through Dataset.get(..., alias=...) |
Datasets |
Curves with one title and several series |
Logging on purpose |
Final numbers as report_single_value |
Logging on purpose |
| Extra outputs as artifacts, not loose files | Artifacts |
| Weights registered as a model, with a tag | Models and the registry |
- Take this structure into a project you work on, adapting the training body to your own code.
- Get one tracked run green locally, with scalars, a single value, an artifact, and a registered model.
- Version your input data as a ClearML Dataset and consume it with an
alias. - Start an agent, then clone your run in the UI, change one hyperparameter, and enqueue it. Do not touch the terminal on the executing machine.
- Compare the two runs and confirm "hide identical values" shows exactly the one parameter you changed.
What you can now do, and what comes next
You can track an experiment with two lines, log metrics and artifacts deliberately, register and query models, version datasets incrementally, compare dozens of runs in a table and in code, clone a run and re-launch it on other hardware without SSH, chain runs into a cached pipeline, and diagnose a failed task in a fixed order. That is a working practitioner's toolkit, enough to own the experiment-tracking and reproducibility story on a real project.
| Can you… | |
|---|---|
| Say what a Task is? | One recorded, re-executable run |
| Name the four parts of ClearML? | SDK, API/web/file server, agent, queue |
Say why Task.init goes first? |
It patches frameworks at import time |
| List what is captured automatically? | Code, diff, packages, args, TB metrics, models, console |
Explain title versus series? |
One chart per title, one line per series |
Say when to use report_single_value? |
Facts about the run, for comparison columns |
Explain why connect is bidirectional? |
Local upload; on an agent, download and override |
| Say where artifacts go by default? | The file server: set output_uri for anything large |
| Name the two dataset fetch modes? | get_local_copy cached, get_mutable_local_copy fresh |
| Say what an agent does with a queued task? | Rebuilds env, clones commit, applies diff, runs it |
Explain what execute_remotely does? |
Registers and enqueues, then exits locally |
| Name the first tab to open on a failure? | Console, then Execution |
Mid-level takes every one of those topics further: hyperparameter optimisation with HyperParameterOptimizer and Optuna, task types and the services queue, agent modes and caching in depth, docker mode and clearml-task for launching unmodified code, remote debugging with clearml-session, dataset internals and squashing, the model registry as a promotion workflow, ClearML Serving, autoscalers, reports, and the CI patterns that make a training run a pull-request check.
Senior then covers what you own when ClearML is your team's platform: the self-hosted deployment and its three data stores, the access and credential model, multi-tenancy across teams, storage cost and retention, Elasticsearch and MongoDB scaling, upgrade and backup procedure, audit and lineage for regulated work, GPU fleet economics, incident playbooks, and where ClearML stops and a feature store, a data warehouse, or a dedicated serving stack begins.