Welcome to daskperiment’s documentation!¶
daskperiment is a tool to perform reproducible machine learning experiment. It enables users to define and manage the history of trials (given parameters, results and execution environment).
This package is built on Dask, a package for parallel computing with task scheduling. Each experiment trial is internally expressed as Dask computation graph, and can be executed in parallel.
It can be used both on Jupyter and command line (and also on standard Python interpreter). The benefits of daskperiemnt are:
- Compatibility with standard Python/Jupyter environment (and optionally with standard KVS).
- No need to set up server applications
- No need to registrate on any cloud services
- Run on standard / customized Python shells
- Intuitive user interface
- Few modifications on existing codes are needed
- Trial histories are logged automatically (no need to write additional codes for logging)
- Dask compatible API
- Easily accessible experiments history (with pandas basic operations)
- Less managiment works on Git (no need to make branch per trials)
- (Experimental) Web dashboard to manage trial history
- Traceability of experiment related information
- Trial result and its (hyper) parameters.
- Code contexts
- Environment information
- Device information
- OS information
- Python version
- Installed Python packages and its version
- Git information
- Reproducibility
- Check function purity (each step should return the same output for the same inputs)
- Automatic random seeding
- Auto saving and loading of previous experiment history
- Parallel execution of experiment steps
- Experiment sharing
- Redis backend
- MongoDB backend
Contents:
What’s new¶
v0.5.0¶
Enhancement¶
- MongoDB backend support
- Redesign web dashboard
Bug fix¶
- Trial result may be incorrect if it is ran from multiple threads
v0.4.0¶
Enhancement¶
- Added verbose option to Experiment.get_history (default False)
- Environment now collects following info:
- Detailed CPU info with py-cpuinfo
- conda info
- numpy.show_config()
- scipy.show_config()
- pandas.show_versions()
- Dashboard now supports to show summary, code and environment info.
Bug fix¶
- Log output of “number of installed python packages” is incorrect
- Dashboard can’t switch display metric name
v0.3.0¶
Enhancement¶
- Function purity check
- Random seed handling
- Parameter now supports default value
- Capture Git info
- (Experimental) Minimal dashboard
Bug fix¶
- Experiment.get_history with no parameters results in empty DataFrame.
- Unable to change log level and its handler one time.
v0.2.0¶
Enhancement¶
- Redis backend support
- “Result Type” column is added to a DataFrame which Experiment.get_history returns (as pandas may change column dtype)
v0.1.1¶
Bug fix¶
- str input for experiment function may be incorrectly regarded as a parameter if the same parameter exists.
- Experiment.get_histoy raises TypeError in pandas 0.22 or earlier
- Experiment may raise AttributeError depending on pip version
v0.1.0¶
- Initial release
Quickstart¶
This section describes a minimal example. First, create daskperiment.Experiment instance. This instance controlls an experiment, a chain of functions to output value and a collection of input variables.
Note
Unrelated logs are omitted in following examples.
>>> import numpy as np
>>> import daskperiment
>>> ex = daskperiment.Experiment(id='quickstart_pj')
...
>>> ex
Experiment(id: quickstart_pj, trial_id: 0, backend: LocalBackend('daskperiment_cache/quickstart_pj'))
Then, use Experiment.parameter method to define parameters (input variables for the experiment). The actual value of each parameter can be changed in every trial.
>>> a = ex.parameter('a')
>>> b = ex.parameter('b')
>>> a
Parameter(a: Undefined)
Next, define each experiment step (function) by decorating with Experiment instance (@ex).
Note that the function to output the final result (mostly objective value to be minimized or maximized) must be decorated with Experiment.result. The chain of these functions are expressed as Dask.Delayed instance.
>>> @ex
>>> def prepare_data(a, b):
>>> return a + b
>>> @ex.result
>>> def calculate_score(s):
>>> return 10 / s
>>> d = prepare_data(a, b)
>>> s = calculate_score(d)
>>> s
Delayed('calculate_score-ebe2d261-8903-45e1-b224-72b4c886e4c5')
Thus, you can visualize computation graph via .visualize method.
>>> s.visualize()
Use Experiment.set_parameters method to set parameters for a trial. After setting parameters, Parameter variable and experiment result can be computable.
Parameters are recommended to be a scalar (or lightweight value) because these are stored as history (for example, passing filename as a parameter is preffered rather than passing DataFrame).
>>> ex.set_parameters(a=1, b=2)
...
>>> s.compute()
... [INFO] Started Experiment (trial id=1)
...
... [INFO] Finished Experiment (trial id=1)
...
3.3333333333333335
You can update any parameters for next trial. Every trials can be distinguished by trial id.
>>> ex.set_parameters(b=3)
>>> s.compute()
...
... [INFO] Started Experiment (trial id=2)
...
... [INFO] Finished Experiment (trial id=2)
...
2.5
After some trials, you can retrieve parameter values specifying trial id.
>>> ex.get_parameters(trial_id=1)
{'a': 1, 'b': 2}
>>> ex.get_parameters(trial_id=2)
{'a': 1, 'b': 3}
Experiment.get_history returns a DataFrame which stores the history of trial parameters and its results. You can select desirable trial using pandas basic operation.
>>> ex.get_history()
a b Result Result Type Success Finished \
1 1 2 3.333333 <class 'float'> True 2019-02-03 XX:XX:XX.XXXXXX
2 1 3 2.500000 <class 'float'> True 2019-02-03 XX:XX:XX.XXXXXX
Process Time Description
1 00:00:00.014183 NaN
2 00:00:00.012354 NaN
When any error occurs during the trial, Experiment instance stores the log as failed trial. The “Description” column contains the error detail.
>>> ex.set_parameters(a=1, b=-1)
>>> s.compute()
...
ZeroDivisionError: division by zero
>>> ex.get_history()
a b Result Result Type Success Finished \
1 1 2 3.333333 <class 'float'> True 2019-02-03 XX:XX:XX.XXXXXX
2 1 3 2.500000 <class 'float'> True 2019-02-03 XX:XX:XX.XXXXXX
3 1 -1 NaN None False 2019-02-03 XX:XX:XX.XXXXXX
Process Time Description
1 00:00:00.014183 NaN
2 00:00:00.012354 NaN
3 00:00:00.015954 ZeroDivisionError(division by zero)
Handling Intermediate Result¶
Next example shows how to retrieve an intermediate result of the chain.
The only difference is using Experiment.persist decorator. It makes Experiment instance to keep the decorated function’s intermediate result. After definition, rebuilt the same workflow using the persisted function.
Note that an intermediate result is saved as a pickle file named with its function name which must be unique in the experiment.
>>> @ex.persist
>>> def prepare_data(a, b):
>>> return a + b
>>> d = prepare_data(a, b)
>>> s = calculate_score(d)
... [WARNING] Code context has been changed: prepare_data
... [WARNING] @@ -1,3 +1,3 @@
... [WARNING] -@ex
... [WARNING] +@ex.persist
... [WARNING] def prepare_data(a, b):
... [WARNING] return a + b
...
Note
If you execute the code above, daskperiment outputs some “WARNING” indicating code contexts has been changed. It’s because daskperiment automatically tracks code context to guarantee reproducibility.
Let’s perform some trials.
>>> ex.set_parameters(a=1, b=2)
>>> s.compute()
...
... [INFO] Finished Experiment (trial id=4)
...
3.3333333333333335
>>> ex.set_parameters(a=3, b=2)
>>> s.compute()
...
... [INFO] Finished Experiment (trial id=5)
...
2.0
You can retrieve intermediate results via Experiment.get_persisted method by specifying function name and trial id.
>>> ex.get_persisted('prepare_data', trial_id=4)
...
3
>>> ex.get_persisted('prepare_data', trial_id=5)
...
5
Monitoring Metrics¶
You may need to monitor transition of some metrics during each trial. In each experiment function, you can call Experiment.save_metric to save metric with its key (name) and epoch.
>>> @ex.result
>>> def calculate_score(s):
>>> for i in range(100):
>>> ex.save_metric('dummy_score', epoch=i, value=100 - np.random.random() * i)
>>> return 10 / s
>>> d = prepare_data(a, b)
>>> s = calculate_score(d)
...
>>> ex.set_parameters(a=1, b=2)
>>> s.compute()
...
... [INFO] Finished Experiment (trial id=6)
...
3.3333333333333335
After a trial, you can load saved metric using Experiment.load_metric specifying its name and trial_id. As it returns metrics as a DataFrame, you can easily investigate them.
>>> dummy_score = ex.load_metric('dummy_score', trial_id=6)
>>> dummy_score.head()
Trial ID 6
Epoch
0 100.000000
1 99.925724
2 99.616405
3 98.527259
4 97.086730
Perform another trial.
>>> ex.set_parameters(a=3, b=4)
>>> s.compute()
...
... [INFO] Finished Experiment (trial id=7)
...
1.4285714285714286
To compare metrics between trials, pass multiple trial ids to Experiment.load_metric.
>>> ex.load_metric('dummy_score', trial_id=[6, 7]).head()
Trial ID 6 7
Epoch
0 100.000000 100.000000
1 99.925724 99.497605
2 99.616405 99.459706
3 98.527259 98.027079
4 97.086730 99.517617
Check Code Context¶
During the trials, daskperiment tracks code contexts decorated with Experiment decorators.
To check the tracked code contexts, use Experiment.get_code specifying trial id (if is not specified, it returns current code).
>>> ex.get_code()
@ex.persist
def prepare_data(a, b):
return a + b
@ex.result
def calculate_score(s):
for i in range(100):
ex.save_metric('dummy_score', epoch=i, value=100 - np.random.random() * i)
return 10 / s
>>> ex.get_code(trial_id=1)
@ex
def prepare_data(a, b):
return a + b
@ex.result
def calculate_score(s):
return 10 / s
Each code context is also saved as a text file per trial id. So it is easy to handle by diff tools and Git.
Function Purity And Handling Randomness¶
To make the experiment reproducible, all the experiment step should be “pure” function (it always returns the same outputs if the inputs to it are the same). In other words, the function should not have internal state nor randomness.
daskperiment checks whether each experiment step is pure. It internally stores the hash of inputs and output, and issues a warning if its output is changed even though the inputs are unchanged.
To illustrate this, add randomness to the example code.
>>> @ex.result
>>> def calculate_score(s):
>>> for i in range(100):
>>> ex.save_metric('dummy_score', epoch=i, value=100 - np.random.random() * i)
>>> return 10 / s + np.random.random()
>>> d = prepare_data(a, b)
>>> s = calculate_score(d)
Because of the code change, it outputs the different results even though its inputs (parameters) are the same. In this case, daskperiment issuess the warning.
>>> s.compute()
...
... [INFO] Random seed is not provided, initialized with generated seed: 1336143935
...
... [WARNING] Experiment step result is changed with the same input: (step: calculate_score, args: (7,), kwargs: {})
... [INFO] Finished Experiment (trial id=8)
2.1481070929378823
This function outputs different result in every trial because of the randomness. To make the function reproducible, random seed should be provided.
To do this, pass seed argument to compute method. Note that this trial issue the warning because its result is different to the previous result (no seed).
>>> s.compute(seed=1)
...
... [INFO] Random seed is initialized with given seed: 1
...
... [WARNING] Experiment step result is changed with the same input: (step: calculate_score, args: (7,), kwargs: {})
... [INFO] Finished Experiment (trial id=9)
1.7552163303435249
Another trial with the same seed doesn’t issue the warning, because the result is unchanged.
>>> s.compute(seed=1)
...
... [INFO] Random seed is initialized with given seed: 1
...
... [INFO] Finished Experiment (trial id=9)
1.7552163303435249
Save Experiment Status¶
daskperiment automatically saves its internal state when a experiment result is computed (when .compute is called). Also, Experiment instance recovers previous state when it is instanciated.
Following example instanciates Experiment instance using the same id as above. Thus, the created Experiment recovers its previous trial history.
>>> ex_new = daskperiment.Experiment(id='quickstart_pj')
Calling .get_history returns information of previous trials.
>>> ex_new.get_history()
...
Also, Experiment instance automatically detects the environment change from its previous trial. Following is a sample log when package update is detected (pandas 0.23.4 -> 0.24.0).
... [INFO] Loaded Experiment(id: quickstart_pj, trial_id: 14) from path=daskperiment_cache/quickstart_pj/quickstart_pj.pkl
... [WARNING] Installed Python packages have been changed
... [WARNING] @@ -142 +142 @@
... [WARNING] -pandas 0.23.4 (/Users/sinhrks/anaconda/lib/python3.6/site-packages)
... [WARNING] +pandas 0.24.0 (/Users/sinhrks/anaconda/lib/python3.6/site-packages)
Dashboard¶
daskperiment supports a web dashboard to check experiment histories.
Launch From Script¶
To launch the dashboard from script, use Experiment.start_dashboard. It should be non-blocking when called from interactive shell like Jupyter, and be blocking when executed as a file.
>>> ex = daskperiment.Experiment('your_experiment_id')
>>> ex.start_dashboard()
Launch From Terminal¶
To launch from the terminal, use daskperimentboard command providing your experiment id.
daskperimentboard your_experiment_id
Command Line Interface¶
daskperiment also supports execution from command line.
First, prepare a Python script to define experiment. The usage of Experiment class is all the same as Jupyter example. daskperiment regards the result of a function decorated with Experiment.result (calculate_score function in below case) as experiment output.
The below is a prepared script example named “simple_experiment.py”.
import daskperiment
ex = daskperiment.Experiment(id='simple_experiment_pj')
a = ex.parameter('a')
b = ex.parameter('b')
@ex
def prepare_data(a, b):
return a + b
@ex.result
def calculate_score(s):
return s + 1
d = prepare_data(a, b)
calculate_score(d)
You can provide parameter values from command line options using key=value format. daskperinemt automatically parse parameters and perform computation.
python simple_experiment.py a=1 b=2
... [INFO] Initialized new experiment: Experiment(id: simple_experiment_pj, trial_id: 0, backend: LocalBackend('daskperiment_cache/simple_experiment_pj'))
...
... [INFO] Finished Experiment (trial id=1)
...
Let’s perform another trial using different parameters. daskperiment automatically saves trial history as the same as Jupyter example (see trial id is incremented).
python ../scripts/simple_experiment.py a=3 b=2
... [INFO] Loading Experiment from file: daskperiment_cache/simple_experiment_pj/simple_experiment_pj.pkl
...
... [INFO] Finished Experiment (trial id=2)
...
To confirm the experiment results, instanciate Experiment specifying the id of the script and use Experiment.get_history.
>>> import daskperiment
>>> ex = daskperiment.Experiment(id='simple_experiment_pj')
>>> ex.get_history()
a b Result Result Type Success Finished \
1 1 2 4 <class 'int'> True 2019-02-03 XX:XX:XX.XXXXXX
2 3 2 6 <class 'int'> True 2019-02-03 XX:XX:XX.XXXXXX
Process Time Description
1 00:00:00.009560 NaN
2 00:00:00.006512 NaN
Command Line Options¶
Use –seed option to provide random seed from terminal,
python random_experiment.py --seed 1
Tips¶
Track Data Version¶
Most of experiments relies on external data source such as CSV, relational DB, etc. Sometimes experiment result is unexpectedly changed because of external data changes. In such cases, even though daskperiment checks function purity and issues a warning, users may not determine the exact reason.
It is recommended that data loading function is defined as a separate step, because if you receive a warning from that loading function, you can understand that the external data has changed.
>>> @ex
>>> def load_user_data(filename):
>>> return pd.read_csv(filename)
Check Trial ID During Execution¶
You may want to know the current trial id during the trial. Then use Experiment.current_trial_id to check current trial id.
>>> @ex
>>> def experiment_step(a, b):
>>> print('debug_information', ex.current_trial_id)
>>> ...
You cannnot refer to current_trial_id outside of the experiment step because the id is generated when the trial is performed.
>>> ex.current_trial_id
daskperiment.core.errors.TrialIDNotFoundError: Current Trial ID only exists during a trial execution
To check the last trial id of the experiment outside of the experiment step, use .trial_id property.
>>> ex.trial_id
3
The next trial should be numbered as .trial_id + 1, if no other trial is triggered until your execution. Note that .trial_id cannot be referred during a trial execution to avoid confusion between .trial_id and .current_trial_id.
Backend¶
daskperiment uses Backend classes to define how and where experiment results are saved. Currently, following backends are supported.
- LocalBackend: Information is stored in local files. This is for personal usage with single PC. Use this backend when you don’t need to share information with others or to move file(s) to another PC.
- RedisBackend: Information is stored in Redis and can be shared in a small team or among several PCs.
- MongoBackend: Information is stored in MongoDB and can be shared in a team or among PCs.
You can specify required Backend via backend keyword in Experiment instanciation.
LocalBackend¶
LocalBackend saves information as local files. When you create Experiment instance without backend argument, the Experiment uses LocalBackend to save its information as default.
Note
Unrelated logs are omited in following examples.
>>> import daskperiment
>>> daskperiment.Experiment('local_default_backend')
... [INFO] Creating new cache directory: /Users/sinhrks/Git/daskperiment/daskperiment_cache/local_default_backend
... [INFO] Initialized new experiment: Experiment(id: local_default_backend, trial_id: 0, backend: LocalBackend('daskperiment_cache/local_default_backend'))
...
Experiment(id: local_default_backend, trial_id: 0, backend: LocalBackend('daskperiment_cache/local_default_backend'))
To change the directory location, specify the path as pathlib.Path instance to backend argument.
>>> import pathlib
>>> daskperiment.Experiment('local_custom_backend', backend=pathlib.Path('my_dir'))
... [INFO] Creating new cache directory: /Users/sinhrks/Git/daskperiment/my_dir
... [INFO] Initialized new experiment: Experiment(id: local_custom_backend, trial_id: 0, backend: LocalBackend('my_dir'))
...
Experiment(id: local_custom_backend, trial_id: 0, backend: LocalBackend('my_dir'))
The following table shows information and saved location under specified cache directory.
Information | Format | Path |
---|---|---|
Experiment status (internal state) | Pickle | <experiment id>.pkl |
Experiment history | Pickle | <experiment id>.pkl |
Persisted results | Pickle | persist/<experiment id>_<function name>_<trial id>.pkl |
Metrics | Pickle | <experiment id>.pkl |
Function input & output hash | Pickle | <experiment id>.pkl |
Code contexts | Text | code/<experiment id>_<trial id>.py |
Platform information | Text(JSON) | environmemt/device_<experiment id>_<trial id>.json |
CPU information | Text(JSON) | environmemt/cpu_<experiment id>_<trial id>.json |
Python information | Text(JSON) | environmemt/python_<experiment id>_<trial id>.json |
NumPy information (numpy.show_config) | Text | environmemt/numpy_<experiment id>_<trial id>.txt |
SciPy information (scipy.show_config) | Text | environmemt/scipy_<experiment id>_<trial id>.txt |
pandas information (pandas.show_versions) | Text | environmemt/pandas_<experiment id>_<trial id>.txt |
conda information (conda info) | Text | environmemt/conda_<experiment id>_<trial id>.txt |
Git information | Text(JSON) | environmemt/git_<experiment id>_<trial id>.json |
Python package information | Text | environmemt/requirements_<experiment id>_<trial id>.txt |
RedisBackend¶
RedisBackend saves information using Redis. To use RedisBackend, one of the simple ways is specifying Redis URI as backend argument.
>>> daskperiment.Experiment('redis_uri_backend', backend='redis://localhost:6379/0')
... [INFO] Initialized new experiment: Experiment(id: redis_uri_backend, trial_id: 0, backend: RedisBackend('redis://localhost:6379/0'))
...
Experiment(id: redis_uri_backend, trial_id: 0, backend: RedisBackend('redis://localhost:6379/0'))
Or, you can use redis.ConnectionPool.
>>> import redis
>>> pool = redis.ConnectionPool.from_uri('redis://localhost:6379/0')
>>> daskperiment.Experiment('redis_pool_backend', backend=pool)
... [INFO] Initialized new experiment: Experiment(id: redis_pool_backend, trial_id: 0, backend: RedisBackend('redis://localhost:6379/0'))
...
Experiment(id: redis_pool_backend, trial_id: 0, backend: RedisBackend('redis://localhost:6379/0'))
The following table shows information and saved location under specified Redis database.
Information | Format | Key |
---|---|---|
Experiment status (internal state) | Text | <experiment id>:trial_id |
Experiment history (parameters) | Pickle | <experiment id>:parameter:<trial id> |
Experiment history (results) | Pickle | <experiment id>:history:<trial id> |
Persisted results | Pickle | <experiment id>:persist:<function name>:<trial id> |
Metrics | Pickle | <experiment id>:metric:<metric name>:<trial id> |
Function input & output hash | Text | <experiment id>:step_hash:<function name>-<input hash> |
Code contexts | Text | <experiment id>:code:<trial id> |
Platform information | Text(JSON) | <experiment id>:device:<trial id> |
CPU information | Text(JSON) | <experiment id>:cpu:<trial id> |
Python information | Text(JSON) | <experiment id>:python:<trial id> |
NumPy information (numpy.show_config) | Text | <experiment id>:numpy:<trial id> |
SciPy information (scipy.show_config) | Text | <experiment id>:scipy:<trial id> |
pandas information (pandas.show_versions) | Text | <experiment id>:pandas:<trial id> |
conda information (conda info) | Text | <experiment id>:conda:<trial id> |
Git information | Text(JSON) | <experiment id>:git:<trial id> |
Python package information | Text | <experiment id>:requirements:<trial id> |
MongoBackend¶
MongoBackend saves information using MongoDB. To use MongoBackend, one of the simple ways is specifying MongoDB URI as backend argument.
>>> daskperiment.Experiment('mongo_uri_backend', backend='mongodb://localhost:27017/test_db')
... [INFO] Initialized new experiment: Experiment(id: redis_uri_backend, trial_id: 0, backend: MongoBackend('mongodb://localhost:27017/test_db'))
...
Experiment(id: mongo_uri_backend, trial_id: 0, backend: MongoBackend('mongodb://localhost:27017/test_db'))
Or, you can use pymogo.database.Database. Note that you cannot pass MongoClient as backend because it doesn’t specify the backend database.
>>> import pymongo
>>> client = pymongo.MongoClient('mongodb://localhost:27017/')
>>> db = client.test_db
>>> db
Database(MongoClient(host=['localhost:27017'], document_class=dict, tz_aware=False, connect=True), 'test_db')
>>> daskperiment.Experiment('mongo_db_backend', backend=db)
... [INFO] Initialized new experiment: Experiment(id: mongo_db_backend, trial_id: 0, backend: MongoBackend('mongodb://localhost:27017/test_db'))
...
Experiment(id: mongo_db_backend, trial_id: 0, backend: MongoBackend('mongodb://localhost:27017/test_db'))
The MongoBackend creates a document collection named experiment id under the database specified in MongoBackend. The following table shows document created under the collection.
Information | Format | Document |
---|---|---|
Experiment status (internal state) | Text | {‘experiment_id’: <experiment id>, ‘category’: ‘trial_id’} |
Experiment history (parameters) | Pickle | {‘experiment_id’: <experiment id>, ‘category’: ‘trial’, ‘trial_id’: <trial id>, ‘parameter’=<parameters>, ‘history’=<history>} |
Experiment history (results) | Pickle | (same document as parameters) |
Persisted results | Pickle | {‘experiment_id’: <experiment id>, ‘category’: ‘persist’, ‘step’: <function name>, ‘trial_id’: <trial id>} |
Metrics | Pickle | {‘experiment_id’: <experiment id>, ‘category’: ‘metric’, ‘metric_key’: <metric name>, ‘trial_id’: <trial id>} |
Function input & output hash | Text | {‘experiment_id’: <experiment id>, ‘category’: ‘step_hash’, ‘input_hash’: <function name>-<input hash>} |
Code contexts | Text | {‘experiment_id’: <experiment id>, ‘category’: ‘code’, ‘trial_id’: <trial id>} |
Platform information | Text(JSON) | {‘experiment_id’: <experiment id>, ‘category’: ‘environment’, ‘trial_id’: <trial id>, ‘device’: <device>, …} |
CPU information | Text(JSON) | (same document as device) |
Python information | Text(JSON) | (same document as device) |
NumPy information (numpy.show_config) | Text | (same document as device) |
SciPy information (scipy.show_config) | Text | (same document as device) |
pandas information (pandas.show_versions) | Text | (same document as device) |
conda information (conda info) | Text | (same document as device) |
Git information | Text(JSON) | (same document as device) |
Python package information | Text | (same document as device) |