minerva-opt 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- minerva_opt/__init__.py +3 -0
- minerva_opt/callbacks/__init__.py +9 -0
- minerva_opt/callbacks/ray_callbacks.py +89 -0
- minerva_opt/pipelines/__init__.py +3 -0
- minerva_opt/pipelines/hyperparameter_search.py +154 -0
- minerva_opt-1.0.0.dist-info/METADATA +141 -0
- minerva_opt-1.0.0.dist-info/RECORD +9 -0
- minerva_opt-1.0.0.dist-info/WHEEL +5 -0
- minerva_opt-1.0.0.dist-info/top_level.txt +1 -0
minerva_opt/__init__.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shutil
|
|
3
|
+
import tempfile
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import lightning.pytorch as L
|
|
7
|
+
from ray import train
|
|
8
|
+
from ray._common.usage.usage_lib import TagKey, record_extra_usage_tag
|
|
9
|
+
from ray.train import Checkpoint
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TrainerReportOnIntervalCallback(L.Callback):
|
|
13
|
+
|
|
14
|
+
CHECKPOINT_NAME = "checkpoint.ckpt"
|
|
15
|
+
|
|
16
|
+
def __init__(self, interval: int = 1) -> None:
|
|
17
|
+
super().__init__()
|
|
18
|
+
self.trial_name = train.get_context().get_trial_name()
|
|
19
|
+
self.local_rank = train.get_context().get_local_rank()
|
|
20
|
+
self.tmpdir_prefix = Path(tempfile.gettempdir(), self.trial_name).as_posix()
|
|
21
|
+
self.interval = interval
|
|
22
|
+
self.step = 0
|
|
23
|
+
if os.path.isdir(self.tmpdir_prefix) and self.local_rank == 0:
|
|
24
|
+
shutil.rmtree(self.tmpdir_prefix)
|
|
25
|
+
|
|
26
|
+
record_extra_usage_tag(TagKey.TRAIN_LIGHTNING_RAYTRAINREPORTCALLBACK, "1")
|
|
27
|
+
|
|
28
|
+
def on_train_epoch_end(self, trainer: L.Trainer, pl_module: L.LightningModule) -> None:
|
|
29
|
+
metrics = trainer.callback_metrics
|
|
30
|
+
metrics = {k: v.item() for k, v in metrics.items()}
|
|
31
|
+
metrics["epoch"] = trainer.current_epoch
|
|
32
|
+
metrics["step"] = trainer.global_step
|
|
33
|
+
|
|
34
|
+
tmpdir = Path(self.tmpdir_prefix, str(trainer.current_epoch)).as_posix()
|
|
35
|
+
os.makedirs(tmpdir, exist_ok=True)
|
|
36
|
+
|
|
37
|
+
if self.step % self.interval == 0:
|
|
38
|
+
ckpt_path = Path(tmpdir, self.CHECKPOINT_NAME).as_posix()
|
|
39
|
+
trainer.save_checkpoint(ckpt_path, weights_only=False)
|
|
40
|
+
checkpoint = Checkpoint.from_directory(tmpdir)
|
|
41
|
+
train.report(metrics=metrics, checkpoint=checkpoint)
|
|
42
|
+
else:
|
|
43
|
+
train.report(metrics=metrics)
|
|
44
|
+
|
|
45
|
+
trainer.strategy.barrier()
|
|
46
|
+
|
|
47
|
+
if self.local_rank == 0:
|
|
48
|
+
shutil.rmtree(tmpdir)
|
|
49
|
+
|
|
50
|
+
self.step += 1
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TrainerReportKeepOnlyLastCallback(L.Callback):
|
|
54
|
+
|
|
55
|
+
CHECKPOINT_NAME = "checkpoint.ckpt"
|
|
56
|
+
|
|
57
|
+
def __init__(self) -> None:
|
|
58
|
+
super().__init__()
|
|
59
|
+
self.trial_name = train.get_context().get_trial_name()
|
|
60
|
+
self.local_rank = train.get_context().get_local_rank()
|
|
61
|
+
self.tmpdir_prefix = Path(tempfile.gettempdir(), self.trial_name).as_posix()
|
|
62
|
+
if os.path.isdir(self.tmpdir_prefix) and self.local_rank == 0:
|
|
63
|
+
shutil.rmtree(self.tmpdir_prefix)
|
|
64
|
+
|
|
65
|
+
record_extra_usage_tag(TagKey.TRAIN_LIGHTNING_RAYTRAINREPORTCALLBACK, "1")
|
|
66
|
+
|
|
67
|
+
def on_train_epoch_end(self, trainer: L.Trainer, pl_module: L.LightningModule) -> None:
|
|
68
|
+
metrics = trainer.callback_metrics
|
|
69
|
+
metrics = {k: v.item() for k, v in metrics.items()}
|
|
70
|
+
metrics["epoch"] = trainer.current_epoch
|
|
71
|
+
metrics["step"] = trainer.global_step
|
|
72
|
+
|
|
73
|
+
tmpdir = Path(self.tmpdir_prefix, "last").as_posix()
|
|
74
|
+
|
|
75
|
+
# Delete previous epoch's checkpoint before writing the new one
|
|
76
|
+
if os.path.isdir(tmpdir):
|
|
77
|
+
shutil.rmtree(tmpdir)
|
|
78
|
+
os.makedirs(tmpdir, exist_ok=True)
|
|
79
|
+
|
|
80
|
+
ckpt_path = Path(tmpdir, self.CHECKPOINT_NAME).as_posix()
|
|
81
|
+
trainer.save_checkpoint(ckpt_path, weights_only=False)
|
|
82
|
+
|
|
83
|
+
checkpoint = Checkpoint.from_directory(tmpdir)
|
|
84
|
+
train.report(metrics=metrics, checkpoint=checkpoint)
|
|
85
|
+
|
|
86
|
+
trainer.strategy.barrier()
|
|
87
|
+
|
|
88
|
+
if self.local_rank == 0:
|
|
89
|
+
shutil.rmtree(tmpdir)
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
from copy import deepcopy
|
|
2
|
+
from typing import Any, Dict, List, Literal, Optional
|
|
3
|
+
|
|
4
|
+
import lightning.pytorch as L
|
|
5
|
+
from lightning.pytorch.strategies import Strategy
|
|
6
|
+
from ray import tune
|
|
7
|
+
from ray.train import CheckpointConfig, RunConfig, ScalingConfig
|
|
8
|
+
from ray.train.lightning import RayDDPStrategy, RayLightningEnvironment, prepare_trainer
|
|
9
|
+
from ray.train.torch import TorchTrainer
|
|
10
|
+
from ray.tune.result_grid import ResultGrid
|
|
11
|
+
from ray.tune.schedulers import ASHAScheduler, TrialScheduler
|
|
12
|
+
from ray.tune.search import ConcurrencyLimiter
|
|
13
|
+
from ray.tune.search.searcher import Searcher
|
|
14
|
+
|
|
15
|
+
from minerva.pipelines.base import Pipeline
|
|
16
|
+
from minerva.utils.typing import PathLike
|
|
17
|
+
from minerva_opt.callbacks.ray_callbacks import TrainerReportOnIntervalCallback
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RayHyperParameterSearch(Pipeline):
|
|
21
|
+
"""Hyperparameter search pipeline using Ray Tune and PyTorch Lightning.
|
|
22
|
+
|
|
23
|
+
Supports any Ray Tune search algorithm via the `search_alg` parameter in
|
|
24
|
+
`_search`. When no algorithm is provided, Ray's default random/grid search
|
|
25
|
+
is used. To use Bayesian optimization, pass a `HyperOptSearch` instance.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
model: type,
|
|
31
|
+
search_space: Dict[str, Any],
|
|
32
|
+
log_dir: Optional[PathLike] = None,
|
|
33
|
+
save_run_status: bool = True,
|
|
34
|
+
seed: Optional[int] = None,
|
|
35
|
+
):
|
|
36
|
+
super().__init__(log_dir=log_dir, save_run_status=save_run_status, seed=seed)
|
|
37
|
+
self.model = model
|
|
38
|
+
self.search_space = search_space
|
|
39
|
+
|
|
40
|
+
def _search(
|
|
41
|
+
self,
|
|
42
|
+
data: L.LightningDataModule,
|
|
43
|
+
ckpt_path: Optional[PathLike],
|
|
44
|
+
devices: str = "auto",
|
|
45
|
+
accelerator: str = "auto",
|
|
46
|
+
strategy: Optional[Strategy] = None,
|
|
47
|
+
callbacks: Optional[List[Any]] = None,
|
|
48
|
+
plugins: Optional[List[Any]] = None,
|
|
49
|
+
num_nodes: int = 1,
|
|
50
|
+
debug_mode: bool = False,
|
|
51
|
+
scaling_config: Optional[ScalingConfig] = None,
|
|
52
|
+
run_config: Optional[RunConfig] = None,
|
|
53
|
+
tuner_metric: str = "val_loss",
|
|
54
|
+
tuner_mode: str = "min",
|
|
55
|
+
num_samples: int = 10,
|
|
56
|
+
scheduler: Optional[TrialScheduler] = None,
|
|
57
|
+
search_alg: Optional[Searcher] = None,
|
|
58
|
+
max_concurrent: int = 4,
|
|
59
|
+
max_epochs: int = 100,
|
|
60
|
+
checkpoint_interval: int = 1,
|
|
61
|
+
) -> ResultGrid:
|
|
62
|
+
|
|
63
|
+
def _train_func(config):
|
|
64
|
+
dm = deepcopy(data)
|
|
65
|
+
model = self.model(**config)
|
|
66
|
+
trainer = L.Trainer(
|
|
67
|
+
max_epochs=max_epochs,
|
|
68
|
+
devices=devices,
|
|
69
|
+
accelerator=accelerator,
|
|
70
|
+
strategy=strategy or RayDDPStrategy(find_unused_parameters=True),
|
|
71
|
+
callbacks=callbacks or [TrainerReportOnIntervalCallback(checkpoint_interval)],
|
|
72
|
+
plugins=plugins or [RayLightningEnvironment()],
|
|
73
|
+
enable_progress_bar=False,
|
|
74
|
+
num_nodes=num_nodes,
|
|
75
|
+
enable_checkpointing=False if debug_mode else None,
|
|
76
|
+
)
|
|
77
|
+
trainer = prepare_trainer(trainer)
|
|
78
|
+
trainer.fit(model, dm, ckpt_path=ckpt_path)
|
|
79
|
+
|
|
80
|
+
scheduler = scheduler or ASHAScheduler(
|
|
81
|
+
time_attr="training_iteration",
|
|
82
|
+
metric=tuner_metric,
|
|
83
|
+
mode=tuner_mode,
|
|
84
|
+
max_t=max_epochs,
|
|
85
|
+
grace_period=max(1, max_epochs // 10),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
scaling_config = scaling_config or ScalingConfig(
|
|
89
|
+
num_workers=1, use_gpu=True, resources_per_worker={"GPU": 1}
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
run_config = run_config or RunConfig(
|
|
93
|
+
checkpoint_config=CheckpointConfig(
|
|
94
|
+
num_to_keep=1,
|
|
95
|
+
checkpoint_score_attribute=tuner_metric,
|
|
96
|
+
checkpoint_score_order=tuner_mode,
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
if search_alg is not None:
|
|
101
|
+
search_alg = ConcurrencyLimiter(search_alg, max_concurrent=max_concurrent)
|
|
102
|
+
|
|
103
|
+
ray_trainer = TorchTrainer(
|
|
104
|
+
_train_func,
|
|
105
|
+
scaling_config=scaling_config,
|
|
106
|
+
run_config=run_config,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
tuner = tune.Tuner(
|
|
110
|
+
ray_trainer,
|
|
111
|
+
param_space={"train_loop_config": self.search_space},
|
|
112
|
+
tune_config=tune.TuneConfig(
|
|
113
|
+
metric=tuner_metric,
|
|
114
|
+
mode=tuner_mode,
|
|
115
|
+
num_samples=num_samples,
|
|
116
|
+
scheduler=scheduler,
|
|
117
|
+
search_alg=search_alg,
|
|
118
|
+
),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
results = tuner.fit()
|
|
122
|
+
best = results.get_best_result()
|
|
123
|
+
print(f"Best config: {best.config}")
|
|
124
|
+
print(f"Best {tuner_metric}: {best.metrics.get(tuner_metric)}")
|
|
125
|
+
return results
|
|
126
|
+
|
|
127
|
+
def _test(self, data: L.LightningDataModule, ckpt_path: Optional[PathLike]) -> Any:
|
|
128
|
+
raise NotImplementedError(
|
|
129
|
+
"Load the best checkpoint from the ResultGrid returned by _search "
|
|
130
|
+
"and call trainer.test() directly."
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def _run(
|
|
134
|
+
self,
|
|
135
|
+
data: L.LightningDataModule,
|
|
136
|
+
task: Optional[Literal["search", "test"]] = None,
|
|
137
|
+
ckpt_path: Optional[PathLike] = None,
|
|
138
|
+
**kwargs,
|
|
139
|
+
) -> Any:
|
|
140
|
+
if task == "search" or task is None:
|
|
141
|
+
return self._search(data, ckpt_path, **kwargs)
|
|
142
|
+
elif task == "test":
|
|
143
|
+
return self._test(data, ckpt_path)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def main():
|
|
147
|
+
from jsonargparse import CLI
|
|
148
|
+
|
|
149
|
+
print("Hyper Searching")
|
|
150
|
+
CLI(RayHyperParameterSearch, as_positional=False)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
if __name__ == "__main__":
|
|
154
|
+
main()
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: minerva-opt
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Hyperparameter optimization extensions for Minerva.
|
|
5
|
+
Author-email: Gabriel Gutierrez <gabriel.bgs00@gmail.com>
|
|
6
|
+
Project-URL: Bug Tracker, https://github.com/gabrielbg0/Minerva-OPT/issues
|
|
7
|
+
Project-URL: Homepage, https://github.com/gabrielbg0/Minerva-OPT
|
|
8
|
+
Keywords: Deep Learning,Hyperparameter Optimization,Machine Learning,Pytorch,Ray Tune,Research
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
Requires-Dist: minerva==0.3.10-beta
|
|
19
|
+
Requires-Dist: ray[tune]>=2.55
|
|
20
|
+
Requires-Dist: hyperopt>=0.2.7
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: black; extra == "dev"
|
|
23
|
+
Requires-Dist: flake8; extra == "dev"
|
|
24
|
+
Requires-Dist: pytest; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest-coverage; extra == "dev"
|
|
26
|
+
|
|
27
|
+
# Minerva-OPT
|
|
28
|
+
|
|
29
|
+
[](https://github.com/gabrielbg0/Minerva-OPT/actions/workflows/auto-release.yml)
|
|
30
|
+
|
|
31
|
+
Hyperparameter optimization extensions for [Minerva](https://github.com/discovery-unicamp/Minerva), powered by [Ray Tune](https://docs.ray.io/en/latest/tune/index.html).
|
|
32
|
+
|
|
33
|
+
## Description
|
|
34
|
+
|
|
35
|
+
Minerva-OPT provides a `RayHyperParameterSearch` pipeline that wraps Ray Tune and PyTorch Lightning to run distributed hyperparameter searches on top of any Minerva-compatible model. It supports random search, grid search, and Bayesian optimization (via HyperOpt), with early stopping through the ASHA scheduler.
|
|
36
|
+
|
|
37
|
+
### Features
|
|
38
|
+
|
|
39
|
+
- **Drop-in Minerva pipeline**: inherits from `minerva.pipelines.base.Pipeline`, so it integrates with Minerva's logging, reproducibility, and run-status tracking out of the box.
|
|
40
|
+
- **Flexible search algorithms**: use Ray Tune's default random/grid search or pass any `ray.tune.search.Searcher` (e.g. `HyperOptSearch` for Bayesian optimization).
|
|
41
|
+
- **ASHA early stopping**: trials are stopped early based on intermediate results; `grace_period` and `max_t` are derived automatically from `max_epochs`.
|
|
42
|
+
- **Distributed training**: uses `RayDDPStrategy` and `RayLightningEnvironment` for multi-worker trials.
|
|
43
|
+
- **Checkpointing**: keeps only the best checkpoint per trial, scored on the target metric.
|
|
44
|
+
|
|
45
|
+
## Installation
|
|
46
|
+
|
|
47
|
+
Requires Python 3.10+.
|
|
48
|
+
|
|
49
|
+
### With uv (recommended)
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
uv pip install minerva-opt
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### With pip
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install minerva-opt
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Usage
|
|
62
|
+
|
|
63
|
+
### Random / grid search
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from ray import tune
|
|
67
|
+
from minerva_opt.pipelines.hyperparameter_search import RayHyperParameterSearch
|
|
68
|
+
|
|
69
|
+
search_space = {
|
|
70
|
+
"learning_rate": tune.loguniform(1e-4, 1e-1),
|
|
71
|
+
"hidden_size": tune.choice([64, 128, 256]),
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
pipeline = RayHyperParameterSearch(
|
|
75
|
+
model=MyLightningModel, # class, not instance — instantiated per trial as MyLightningModel(**config)
|
|
76
|
+
search_space=search_space,
|
|
77
|
+
log_dir="logs/",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
results = pipeline.run(data=my_data_module, num_samples=20, max_epochs=50)
|
|
81
|
+
best = results.get_best_result()
|
|
82
|
+
print(best.config)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Bayesian optimization with HyperOpt
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from ray.tune.search.hyperopt import HyperOptSearch
|
|
89
|
+
from hyperopt import hp
|
|
90
|
+
|
|
91
|
+
search_space = {
|
|
92
|
+
"learning_rate": tune.loguniform(1e-4, 1e-1),
|
|
93
|
+
"dropout": tune.uniform(0.1, 0.5),
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
pipeline = RayHyperParameterSearch(
|
|
97
|
+
model=MyLightningModel,
|
|
98
|
+
search_space=search_space,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
results = pipeline.run(
|
|
102
|
+
data=my_data_module,
|
|
103
|
+
search_alg=HyperOptSearch(),
|
|
104
|
+
num_samples=30,
|
|
105
|
+
max_epochs=100,
|
|
106
|
+
tuner_metric="val_loss",
|
|
107
|
+
tuner_mode="min",
|
|
108
|
+
)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Key `run()` parameters
|
|
112
|
+
|
|
113
|
+
| Parameter | Default | Description |
|
|
114
|
+
| --------------------- | ------------ | ------------------------------------------------------ |
|
|
115
|
+
| `data` | — | `LightningDataModule` to train on |
|
|
116
|
+
| `task` | `"search"` | `"search"` to run the sweep |
|
|
117
|
+
| `ckpt_path` | `None` | Resume training from a checkpoint |
|
|
118
|
+
| `num_samples` | `10` | Number of trials to run |
|
|
119
|
+
| `max_epochs` | `100` | Max epochs per trial |
|
|
120
|
+
| `tuner_metric` | `"val_loss"` | Metric to optimize |
|
|
121
|
+
| `tuner_mode` | `"min"` | `"min"` or `"max"` |
|
|
122
|
+
| `search_alg` | `None` | Any `ray.tune.search.Searcher`; `None` = random search |
|
|
123
|
+
| `max_concurrent` | `4` | Max concurrent trials (when using a `search_alg`) |
|
|
124
|
+
| `scheduler` | ASHA | Override the trial scheduler |
|
|
125
|
+
| `scaling_config` | 1 GPU/worker | Override Ray `ScalingConfig` |
|
|
126
|
+
| `checkpoint_interval` | `1` | Save a checkpoint every N epochs |
|
|
127
|
+
| `debug_mode` | `False` | Disable checkpointing for fast iteration |
|
|
128
|
+
|
|
129
|
+
## Requirements
|
|
130
|
+
|
|
131
|
+
- `minerva >= 0.3.10b0`
|
|
132
|
+
- `ray[tune] >= 2.55`
|
|
133
|
+
- `hyperopt >= 0.2.7`
|
|
134
|
+
|
|
135
|
+
## License
|
|
136
|
+
|
|
137
|
+
MIT License. See [LICENSE](LICENSE) for details.
|
|
138
|
+
|
|
139
|
+
## Contact
|
|
140
|
+
|
|
141
|
+
For questions or bug reports, open an issue on the [GitHub issue tracker](https://github.com/gabrielbg0/Minerva-OPT/issues).
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
minerva_opt/__init__.py,sha256=uHOTGjXAEfB6Tvy9JztZTEJqqLJ7FmmMfLI8bN7OZK4,119
|
|
2
|
+
minerva_opt/callbacks/__init__.py,sha256=OKAaTaN3_sbwAuWx0ufZaOiyDsXlq1t7_hfjCDYo4GQ,223
|
|
3
|
+
minerva_opt/callbacks/ray_callbacks.py,sha256=bKWu2Z8tDMlsoz32kZoc_VrqfjxMpT4G9pJeYZSHeyo,3223
|
|
4
|
+
minerva_opt/pipelines/__init__.py,sha256=uHOTGjXAEfB6Tvy9JztZTEJqqLJ7FmmMfLI8bN7OZK4,119
|
|
5
|
+
minerva_opt/pipelines/hyperparameter_search.py,sha256=-0Ghvo-gXY3Z57ctJbvx12nqmT0QlgLwhK9TX6EKkUk,5356
|
|
6
|
+
minerva_opt-1.0.0.dist-info/METADATA,sha256=PrRCunlNIbP6t3UZgRMWtodmXYdLVB-42vps7wshoXo,5576
|
|
7
|
+
minerva_opt-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
8
|
+
minerva_opt-1.0.0.dist-info/top_level.txt,sha256=yWSjtBACKoMzc6a44LuLa5AaDmCKBGeWg27-hrVs1DQ,12
|
|
9
|
+
minerva_opt-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
minerva_opt
|