Closed-Loop Manifold Active Learning with AFL Servers#
This case study runs the manifold active learning campaign in a format amenable with the usage of AFL’s microservice concept:
Connect to the services (Tiled, instrument, agent).
Specify the problem (campaign ID, components, bounds, number of iterations).
Configure data collection: tell the agent how to find each measurement in Tiled.
Convert agent suggestions into measurement targets.
Run one experiment: the instrument server measures and writes each exposure to Tiled, the agent appends the entry IDs to its dataset.
Seed the campaign with the initial composition.
Build the pipeline on the agent server.
Close the loop:
predict, measure the suggestion, append, repeat.
The virtual instrument
ProteinGlycerolTemperaturePhaseMapruns as an AFL-automationAPIServer, exactly like a hardware driver.AFL.double_agent.VirtualInstrumentDriverserves whichever virtual instrument theinstrumentkey of its--configfile names (phase_map_instrument.jsonhere, which also carries the instrument’sinstrument_overrides), takes that instrument’s class name as the server anddriver_name, and exposes every public method of the instrument as a task: this notebook assigns it a sample withset_sampleand enqueues oneexposetask per temperature; the server writes the returned dataset to Tiled with the sample identity and campaign in its attributes and reports the Tiled entry ID.The agent, a
DoubleAgentDriverserver, reads the entries by ID, fits the pipeline and returns the next composition and temperature profile.
Prerequisites#
Three services must be running, all pointed at the same Tiled instance through ~/.afl/config.json (tiled_server, tiled_api_key and the "ports" of the two drivers):
Service |
Example address |
Launch |
|---|---|---|
Tiled |
|
|
|
|
|
|
|
|
The agent server is launched from this directory because the pipeline holds lambdas, the SRSF manifold and the simulator, which have no JSON form.
The settings, the acquisition scores and the Pipeline are defined in helpers.py in this directory, which this notebook and the companion notebook import. The notebook sends the JSON of the pipeline, in which those objects appear as references, and names the module as the pipeline’s environment; the agent server imports it to obtain the live objects behind the references.
The analysis environment needs AFL.double_agent with the manifold extra, AFL.automation and the Tiled client.
Set Up the Environment#
Import the client of the AFL servers, the Tiled helper and, from helpers.py, the settings and functions of the campaign. Everything the pipeline needs lives in that module so that the notebook and the agent server agree on it.
[ ]:
# Uncomment this cell if you need to install AFL-agent with the ``manifold`` extra
# (torch, gpytorch, pyro-ppl, botorch, geoopt, alphashape, trimesh) and AFL-automation.
# !pip install "AFL-agent[manifold] @ git+https://github.com/usnistgov/afl-agent"
# !pip install "AFL-automation @ git+https://github.com/usnistgov/afl-automation"
[1]:
import uuid
import numpy as np
from AFL.automation.APIServer.Client import Client
from AFL.automation.shared.tiled import get_tiled_client
from helpers import BOUNDS_T, LIMITS, XY, pipeline, xarray_to_target # settings, scores and pipeline, see helpers.py in this folder
np.set_printoptions(precision=3, suppress=True)
/home/knv2/codebase/AFL-bioformulations/.venv/lib/python3.11/site-packages/torch/cuda/__init__.py:182: UserWarning: CUDA initialization: The NVIDIA driver on your system is too old (found version 11040). Please update your GPU driver by downloading and installing a new version from the URL: http://www.nvidia.com/Download/index.aspx Alternatively, go to: https://pytorch.org to install a PyTorch version that has been compiled with your version of the CUDA driver. (Triggered internally at /pytorch/c10/cuda/CUDAFunctions.cpp:119.)
return torch._C._cuda_getDeviceCount() > 0
[2]:
# services
INSTRUMENT_HOST, INSTRUMENT_PORT = "localhost", 5052 # ProteinGlycerolTemperaturePhaseMap APIServer
AGENT_HOST, AGENT_PORT = "localhost", 5050 # DoubleAgentDriver APIServer
USERNAME = "afl-agent"
# campaign
N_ITERATIONS = 4 # compositions (one temperature profile each) acquired after the seed
INITIAL_COMPOSITION = {"protein": 40.0, "glycerol": 5.0} # the one composition measured before the campaign
INITIAL_TEMPERATURES = [5.0, 10.0, 20.0, 30.0, 40.0, 45.0] # ... and the temperatures it is measured at
SEED = 1000
# how the instrument's entries are identified in Tiled
INSTRUMENT_NAME = "ProteinGlycerolTemperaturePhaseMap" # driver_name written to every Tiled entry
EXPOSE_TASK = "expose" # task_name written to every Tiled entry
SAMPLE_DIM = "sample"
INPUT_VARIABLES = ["design_space", "phases", "composition"] # pulled from each entry into the agent dataset
PIPELINE_ENVIRONMENT = "helpers" # module importable on the agent server that defines ``pipeline``
Connect to the Services#
get_tiled_client reads the Tiled server and API key from ~/.afl/config.json, the same configuration the servers use. Create a client for the instrument and the agent and log in to both.
[3]:
tiled = get_tiled_client()
instrument = Client(INSTRUMENT_HOST, port=INSTRUMENT_PORT)
agent = Client(AGENT_HOST, port=AGENT_PORT)
for client in (instrument, agent):
client.login(USERNAME)
instrument.enqueue(task_name="status", interactive=True)["return_val"]
[3]:
['Idle', 'Configurations Loaded=3', 'Phases Traced=3', 'Noise Level=0.0']
Specify the Optimization Problem#
Use one campaign ID everywhere. A unique campaign ID prevents measurements from different runs from being combined. The composition components and their bounds are those of the instrument; the temperature range is the one the stage-2 profile is mapped onto.
[4]:
campaign_id = f"manifold-{uuid.uuid4().hex[:8]}"
components = XY # composition components searched in stage 1: protein, glycerol
bounds = {c: LIMITS[c] for c in components}
np.random.seed(SEED)
campaign_id, bounds, BOUNDS_T
[4]:
('manifold-95dad5a5',
{'protein': (0.5, 75.0), 'glycerol': (0.5, 10.0)},
[0.5, 45.0])
Configure Data Collection#
Tell the agent how to find and align the measurements. Every Tiled entry written by the instrument server holds one exposure; driver_name and task_name select the instrument’s expose entries and campaign_id_path points at the campaign attribute that set_sample adds to each of them. The three variables are read from the entry’s dataset and stacked along sample.
[5]:
input_spec = {
"campaign_id": campaign_id,
"sample_dim": SAMPLE_DIM,
"sources": {
name: {
"driver_name": INSTRUMENT_NAME,
"task_name": EXPOSE_TASK,
"campaign_id_path": "attrs.AL_campaign_name",
"source": "dataset",
"path": name,
}
for name in INPUT_VARIABLES
},
}
agent.enqueue(task_name="setup_data_collection", input_spec=input_spec, interactive=True)["return_val"]
[5]:
{'campaign_id': 'manifold-95dad5a5',
'sources': ['design_space', 'phases', 'composition'],
'started_at': '2026-10-27T19:33:48.676258+00:00',
'status': 'success'}
Run One Experiment#
A target is one composition at several temperatures. Each temperature is one exposure of the instrument: assign the sample identity and campaign with set_sample, enqueue expose, and keep the Tiled entry ID the server reports. The agent then reads the entry by ID; since all three input variables come from the same entry, the same ID is given for each of them.
The phase label of each exposure is read back from Tiled only for the progress report below.
[6]:
def measure_sample(iteration, target):
entry_ids = []
for temperature in target["temperatures"]:
temperature = float(temperature)
instrument.enqueue(
task_name="set_sample",
sample_name=f"{campaign_id}-sample-{iteration:03d}-T{temperature:.1f}",
sample_uuid=f"SAM-{uuid.uuid4()}",
AL_campaign_name=campaign_id,
interactive=True,
)
exposure = instrument.enqueue(
task_name=EXPOSE_TASK,
protein=target["protein"],
glycerol=target["glycerol"],
temperature=temperature,
interactive=True,
)
entry_id = exposure.get("tiled_entry_id")
if not entry_id:
raise RuntimeError(
f"the instrument server did not write to Tiled (tiled_status={exposure.get('tiled_status')!r}); "
"check tiled_server and tiled_api_key in ~/.afl/config.json"
)
agent.enqueue(
task_name="append_dataset",
entries={name: entry_id for name in INPUT_VARIABLES},
interactive=True,
)
entry_ids.append(entry_id)
return entry_ids
def read_phases(entry_ids):
return [int(tiled["run_documents"][entry_id]["phases"].read()) for entry_id in entry_ids]
Seed the Campaign#
The campaign starts from a single composition measured at six temperatures.
[7]:
initial_target = {**INITIAL_COMPOSITION, "temperatures": INITIAL_TEMPERATURES}
initial_entry_ids = measure_sample(0, initial_target)
n_measurements = len(initial_entry_ids)
print(f"seed: phases={read_phases(initial_entry_ids)} (n_measurements={n_measurements})")
seed: phases=[1, 1, 1, 0, 0, 0] (n_measurements=6)
Build the Pipeline#
pipeline is the manifold pipeline of helpers.py: the DirichletGPExtrapolator phase classifier, the PhaseMapF1Score benchmark against the simulator, BoTorchAcquisition over the composition with composition_score and SRSFHilbertSphereAcquisitionFunction over the temperature profile with profile_score. Its JSON form records the lambdas, the manifold and the simulator as references; initialize_pipeline receives the JSON together with environment, the module
the agent server imports to obtain the live objects behind those references.
[8]:
for i, op in enumerate(pipeline):
print(i, op.name)
agent.enqueue(
task_name="initialize_pipeline",
pipeline=pipeline.to_dict()["ops"],
name=pipeline.name,
environment=PIPELINE_ENVIRONMENT,
interactive=True,
)["exit_state"]
0 Grid-DesignSpace
1 Grid-Composition
2 DirichletGPExtrapolator-PhaseLabels
3 PhaseMapF1Score
4 BoTorchAcquisition-Composition
5 SRSF-TemperatureProfile
[8]:
'Success!'
Close the Loop#
Each call to predict evaluates the accumulated campaign data on the agent server. Retrieve last_results, turn its suggestion into a target, measure it and append the new exposures before requesting another prediction.
The benchmark variables of a result belong to the model fitted before the new batch, so the F1 score reported at iteration i is that of a model trained on i - 1 acquired compositions.
[9]:
for iteration in range(1, N_ITERATIONS + 1):
agent.enqueue(task_name="predict", AL_campaign_name=campaign_id, interactive=True)
results = agent.get_driver_object("last_results")
target = xarray_to_target(results)
entry_ids = measure_sample(iteration, target)
n_measurements += len(entry_ids)
print(
f"[{iteration:3d}/{N_ITERATIONS}] protein={target['protein']:6.2f} glycerol={target['glycerol']:5.2f} "
f"temperatures={[round(float(t), 1) for t in target['temperatures']]} "
f"phases={read_phases(entry_ids)} "
f"(n_measurements={n_measurements}) "
f"f1={results.benchmark_f1.item():.3f} accuracy={results.benchmark_accuracy.item():.3f}"
)
[ 1/4] protein= 72.95 glycerol=10.00 temperatures=[0.5, 3.0, 27.0, 45.0] phases=[1, 1, 1, 0] (n_measurements=10) f1=0.495 accuracy=0.597
[ 2/4] protein= 6.29 glycerol= 0.80 temperatures=[0.5, 24.0, 27.0, 45.0] phases=[1, 0, 0, 0] (n_measurements=14) f1=0.505 accuracy=0.603
[ 3/4] protein= 4.72 glycerol=10.00 temperatures=[0.5, 21.0, 45.0] phases=[1, 1, 0] (n_measurements=17) f1=0.512 accuracy=0.620
[ 4/4] protein= 72.42 glycerol= 0.50 temperatures=[0.5, 18.0, 21.0, 45.0] phases=[2, 1, 1, 0] (n_measurements=21) f1=0.504 accuracy=0.607
After the final iteration, refit on everything measured. PhaseMapF1Score scores the classifier against the simulator, and the agent archives the result dataset in Tiled next to the campaign’s entries.
[10]:
agent.enqueue(task_name="predict", AL_campaign_name=campaign_id, interactive=True)
results = agent.get_driver_object("last_results")
print(
f"score: campaign={campaign_id} n_measurements={n_measurements} "
f"f1={results.benchmark_f1.item():.3f} accuracy={results.benchmark_accuracy.item():.3f}"
)
score: campaign=manifold-95dad5a5 n_measurements=21 f1=0.519 accuracy=0.627
[11]:
results[["composition_next", "profile_function", "benchmark_f1"]]
[11]:
<xarray.Dataset> Size: 2kB
Dimensions: (next_sample: 1, component: 2, profile_t: 100,
profile_dim: 1)
Coordinates:
* component (component) <U8 64B 'protein' 'glycerol'
* profile_t (profile_t) float64 800B 0.0 0.0101 0.0202 ... 0.9899 1.0
Dimensions without coordinates: next_sample, profile_dim
Data variables:
composition_next (next_sample, component) float64 16B 73.83 2.059
profile_function (profile_t, profile_dim) float64 800B 0.0 4.79e-07 ... 1.0
benchmark_f1 float64 8B 0.5189
Attributes:
sample_uuid: SAM-9bd9e417-7c07-4a54-82de-120fa519b8f1
ag_uuid: AG-e508f98e-2584-46c0-9fd2-010eb4f3ccd4
AL_campaign_name: manifold-95dad5a5