Skip to content

Commit 7335bdc

Browse files
authored
Model CI Release (#146)
Adds an extension to the Nucleus Client for newly introduced Model CI functionality. With the Model CI, extension, users can use Nucleus slice’s to define test cases that describe critical edge case scenarios. A unit test can have one or more evaluation criteria, which define how to apply evaluation functions to a unit test. The newly introduced classes are: UnitTest EvaluationFunction UnitTest Evaluation UnitTestMetric
1 parent 272faeb commit 7335bdc

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+1221
-58
lines changed

.pre-commit-config.yaml

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,14 @@ repos:
1616
pass_filenames: false
1717
language: system
1818

19+
- repo: local
20+
hooks:
21+
- id: system
22+
name: isort
23+
entry: poetry run isort .
24+
pass_filenames: false
25+
language: system
26+
1927
- repo: local
2028
hooks:
2129
- id: system
@@ -31,11 +39,3 @@ repos:
3139
entry: poetry run mypy --ignore-missing-imports nucleus
3240
pass_filenames: false
3341
language: system
34-
35-
- repo: local
36-
hooks:
37-
- id: system
38-
name: isort
39-
entry: poetry run isort .
40-
pass_filenames: false
41-
language: system

.pylintrc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ disable=
99
unused-argument,
1010
no-self-use,
1111
import-outside-toplevel,
12+
too-many-instance-attributes,
13+
no-member,
1214
W0511,
1315
R0914,
1416
R0913,

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,15 @@ All notable changes to the [Nucleus Python Client](https://github.com/scaleapi/n
44
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## [0.4.0](https://github.com/scaleapi/nucleus-python-client/releases/tag/v0.4.0) - 2021-08-12
8+
9+
### Added
10+
- `NucleusClient.modelci` client extension that houses all features related to Model CI, a continuous integration and testing framework for evaluation machine learning models.
11+
- `NucleusClient.modelci.UnitTest`- class to represent a Model CI unit test.
12+
- `NucleusClient.modelci.UnitTestEvaluation`- class to represent an evaluation result of a Model CI unit test.
13+
- `NucleusClient.modelci.UnitTestItemEvaluation`- class to represent an evaluation result of an individual dataset item within a Model CI unit test.
14+
- `NucleusClient.modelci.eval_functions`- Collection class housing a library of standard evaluation functions used in computer vision.
15+
716
## [0.3.0](https://github.com/scaleapi/nucleus-python-client/releases/tag/v0.3.0) - 2021-11-23
817

918
### Added

docs/_templates/python/module.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
:orphan:
33

44
{% endif %}
5-
API Reference
5+
{{ obj.name }}
66
=============
77

88
.. py:module:: {{ obj.name }}

docs/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ Sections
3131
:maxdepth: 4
3232

3333
api/nucleus/index
34+
api/nucleus/modelci/index
3435

3536

3637
Index

nucleus/__init__.py

Lines changed: 9 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@
3535
]
3636

3737
import os
38-
import time
3938
from typing import Dict, List, Optional, Sequence, Union
4039

4140
import pkg_resources
@@ -57,6 +56,7 @@
5756
Segment,
5857
SegmentationAnnotation,
5958
)
59+
from .connection import Connection
6060
from .constants import (
6161
ANNOTATION_METADATA_SCHEMA_KEY,
6262
ANNOTATIONS_IGNORED_KEY,
@@ -106,6 +106,7 @@
106106
from .logger import logger
107107
from .model import Model
108108
from .model_run import ModelRun
109+
from .modelci import ModelCI
109110
from .payload_constructor import (
110111
construct_annotation_payload,
111112
construct_append_payload,
@@ -161,6 +162,9 @@ def __init__(
161162
self._use_notebook = use_notebook
162163
if use_notebook:
163164
self.tqdm_bar = tqdm_notebook.tqdm
165+
self._connection = Connection(self.api_key, self.endpoint)
166+
167+
self.modelci = ModelCI(self.api_key, self.endpoint)
164168

165169
def __repr__(self):
166170
return f"NucleusClient(api_key='{self.api_key}', use_notebook={self._use_notebook}, endpoint='{self.endpoint}')"
@@ -882,29 +886,9 @@ def make_request(
882886
Returns:
883887
Response payload as JSON dict.
884888
"""
885-
endpoint = f"{self.endpoint}/{route}"
886-
887-
logger.info("Posting to %s", endpoint)
888-
889-
for retry_wait_time in RetryStrategy.sleep_times:
890-
response = requests_command(
891-
endpoint,
892-
json=payload,
893-
headers={"Content-Type": "application/json"},
894-
auth=(self.api_key, ""),
895-
timeout=DEFAULT_NETWORK_TIMEOUT_SEC,
896-
)
897-
logger.info(
898-
"API request has response code %s", response.status_code
899-
)
900-
if response.status_code not in RetryStrategy.statuses:
901-
break
902-
time.sleep(retry_wait_time)
903-
904-
if not response.ok:
905-
self.handle_bad_response(endpoint, requests_command, response)
906-
907-
return response.json()
889+
if payload is None:
890+
payload = {}
891+
return self._connection.make_request(payload, route, requests_command) # type: ignore
908892

909893
def handle_bad_response(
910894
self,
@@ -913,6 +897,6 @@ def handle_bad_response(
913897
requests_response=None,
914898
aiohttp_response=None,
915899
):
916-
raise NucleusAPIError(
900+
self._connection.handle_bad_response(
917901
endpoint, requests_command, requests_response, aiohttp_response
918902
)

nucleus/connection.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import time
2+
3+
import requests
4+
5+
from .constants import DEFAULT_NETWORK_TIMEOUT_SEC
6+
from .errors import NucleusAPIError
7+
from .logger import logger
8+
from .retry_strategy import RetryStrategy
9+
10+
11+
class Connection:
12+
"""Wrapper of HTTP requests to the Nucleus endpoint."""
13+
14+
def __init__(self, api_key: str, endpoint: str = None):
15+
self.api_key = api_key
16+
self.endpoint = endpoint
17+
18+
def __repr__(self):
19+
return (
20+
f"Connection(api_key='{self.api_key}', endpoint='{self.endpoint}')"
21+
)
22+
23+
def __eq__(self, other):
24+
return (
25+
self.api_key == other.api_key and self.endpoint == other.endpoint
26+
)
27+
28+
def delete(self, route: str):
29+
return self.make_request({}, route, requests_command=requests.delete)
30+
31+
def get(self, route: str):
32+
return self.make_request({}, route, requests_command=requests.get)
33+
34+
def post(self, payload: dict, route: str):
35+
return self.make_request(
36+
payload, route, requests_command=requests.post
37+
)
38+
39+
def put(self, payload: dict, route: str):
40+
return self.make_request(payload, route, requests_command=requests.put)
41+
42+
def make_request(
43+
self, payload: dict, route: str, requests_command=requests.post
44+
) -> dict:
45+
"""
46+
Makes a request to Nucleus endpoint and logs a warning if not
47+
successful.
48+
49+
:param payload: given payload
50+
:param route: route for the request
51+
:param requests_command: requests.post, requests.get, requests.delete
52+
:return: response JSON
53+
"""
54+
endpoint = f"{self.endpoint}/{route}"
55+
56+
logger.info("Make request to %s", endpoint)
57+
58+
for retry_wait_time in RetryStrategy.sleep_times:
59+
response = requests_command(
60+
endpoint,
61+
json=payload,
62+
headers={"Content-Type": "application/json"},
63+
auth=(self.api_key, ""),
64+
timeout=DEFAULT_NETWORK_TIMEOUT_SEC,
65+
)
66+
logger.info(
67+
"API request has response code %s", response.status_code
68+
)
69+
if response.status_code not in RetryStrategy.statuses:
70+
break
71+
time.sleep(retry_wait_time)
72+
73+
if not response.ok:
74+
self.handle_bad_response(endpoint, requests_command, response)
75+
76+
return response.json()
77+
78+
def handle_bad_response(
79+
self,
80+
endpoint,
81+
requests_command,
82+
requests_response=None,
83+
aiohttp_response=None,
84+
):
85+
raise NucleusAPIError(
86+
endpoint, requests_command, requests_response, aiohttp_response
87+
)

nucleus/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
CX_KEY = "cx"
3030
CY_KEY = "cy"
3131
DATASET_ID_KEY = "dataset_id"
32+
DATASET_ITEM_ID_KEY = "dataset_item_id"
3233
DATASET_LENGTH_KEY = "length"
3334
DATASET_MODEL_RUNS_KEY = "model_run_ids"
3435
DATASET_NAME_KEY = "name"
@@ -48,6 +49,7 @@
4849
GEOMETRY_KEY = "geometry"
4950
HEADING_KEY = "heading"
5051
HEIGHT_KEY = "height"
52+
ID_KEY = "id"
5153
IGNORED_ITEMS = "ignored_items"
5254
IMAGE_KEY = "image"
5355
IMAGE_LOCATION_KEY = "image_location"

nucleus/data_transfer_object/dataset_details.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
from nucleus.data_transfer_object.dict_compatible_model import (
2-
DictCompatibleModel,
3-
)
1+
from nucleus.pydantic_base import DictCompatibleModel
42

53

64
class DatasetDetails(DictCompatibleModel):

nucleus/data_transfer_object/dataset_info.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
from typing import Any, Dict, List, Optional
22

3-
from nucleus.data_transfer_object.dict_compatible_model import (
4-
DictCompatibleModel,
5-
)
3+
from nucleus.pydantic_base import DictCompatibleModel
64

75

86
class DatasetInfo(DictCompatibleModel):

nucleus/data_transfer_object/dataset_size.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
from nucleus.data_transfer_object.dict_compatible_model import (
2-
DictCompatibleModel,
3-
)
1+
from nucleus.pydantic_base import DictCompatibleModel
42

53

64
class DatasetSize(DictCompatibleModel):

nucleus/job.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
JOB_TYPE_KEY,
1212
STATUS_KEY,
1313
)
14+
from nucleus.utils import replace_double_slashes
1415

1516
JOB_POLLING_INTERVAL = 5
1617

@@ -82,11 +83,12 @@ def errors(self) -> List[str]:
8283
'{"annotation":{"label":"car","type":"box","geometry":{"x":50,"y":60,"width":70,"height":80},"referenceId":"bad_ref_id","annotationId":"attempted_annot_upload","metadata":{}},"error":"Item with id bad_ref_id doesn\'t exist."}'
8384
]
8485
"""
85-
return self.client.make_request(
86+
errors = self.client.make_request(
8687
payload={},
8788
route=f"job/{self.job_id}/errors",
8889
requests_command=requests.get,
8990
)
91+
return [replace_double_slashes(error) for error in errors]
9092

9193
def sleep_until_complete(self, verbose_std_out=True):
9294
"""Blocks until the job completes or errors.
@@ -95,17 +97,24 @@ def sleep_until_complete(self, verbose_std_out=True):
9597
verbose_std_out (Optional[bool]): Whether or not to verbosely log while
9698
sleeping. Defaults to True.
9799
"""
100+
start_time = time.perf_counter()
98101
while 1:
99102
status = self.status()
100103
time.sleep(JOB_POLLING_INTERVAL)
101104

102105
if verbose_std_out:
103-
print(f"Status at {time.ctime()}: {status}")
106+
print(
107+
f"Status at {time.perf_counter() - start_time} s: {status}"
108+
)
104109
if status["status"] == "Running":
105110
continue
106111

107112
break
108113

114+
if verbose_std_out:
115+
print(
116+
f"Finished at {time.perf_counter() - start_time} s: {status}"
117+
)
109118
final_status = status
110119
if final_status["status"] == "Errored":
111120
raise JobError(final_status, self)
@@ -132,4 +141,5 @@ def __init__(self, job_status: Dict[str, str], job: AsyncJob):
132141
f"The final status message was: {final_status_message} \n"
133142
f"For more detailed error messages you can call {str(job)}.errors()"
134143
)
144+
message = replace_double_slashes(message)
135145
super().__init__(message)

nucleus/model.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
from typing import Dict, List, Optional, Union
22

3+
import requests
4+
35
from .constants import METADATA_KEY, NAME_KEY, REFERENCE_ID_KEY
46
from .dataset import Dataset
7+
from .job import AsyncJob
58
from .model_run import ModelRun
69
from .prediction import (
710
BoxPrediction,
@@ -159,3 +162,28 @@ def create_run(
159162
model_run.predict(predictions, asynchronous=asynchronous)
160163

161164
return model_run
165+
166+
def evaluate(self, unit_test_names: List[str]) -> AsyncJob:
167+
"""Evaluates this on the specified Unit Tests. ::
168+
169+
import nucleus
170+
client = nucleus.NucleusClient("YOUR_SCALE_API_KEY")
171+
model = client.list_models()[0]
172+
unit_test = client.modelci.create_unit_test(
173+
"sample_unit_test", "YOUR_SLICE_ID"
174+
)
175+
176+
model.evaluate(["sample_unit_test"])
177+
178+
Args:
179+
unit_test_names: list of unit tests to evaluate
180+
181+
Returns:
182+
AsyncJob object of evaluation job
183+
"""
184+
response = self._client.make_request(
185+
{"test_names": unit_test_names},
186+
f"modelci/{self.id}/evaluate",
187+
requests_command=requests.post,
188+
)
189+
return AsyncJob.from_json(response, self._client)

nucleus/modelci/__init__.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"""Model CI Python Library."""
2+
3+
__all__ = [
4+
"ModelCI",
5+
"UnitTest",
6+
]
7+
8+
from .client import ModelCI
9+
from .constants import ThresholdComparison
10+
from .data_transfer_objects.eval_function import (
11+
EvalFunctionEntry,
12+
EvaluationCriterion,
13+
GetEvalFunctions,
14+
)
15+
from .data_transfer_objects.unit_test import CreateUnitTestRequest
16+
from .errors import CreateUnitTestError
17+
from .eval_functions.available_eval_functions import AvailableEvalFunctions
18+
from .unit_test import UnitTest
19+
from .unit_test_evaluation import UnitTestEvaluation, UnitTestItemEvaluation
20+
from .unit_test_metric import UnitTestMetric

0 commit comments

Comments
 (0)