Service Architecture¶
porter
provides a micro-service architecture, in which an app routes traffic to one or more services with well-defined, minimal interfaces. The app is an instance of porter.services.ModelApp
, and services are instances of classes, such as porter.services.PredictionService
, that are derived from porter.services.BaseService
. We’ve outlined basic usage of these classes in previous pages; here we discuss each of them in greater detail.
ModelApp¶
ModelApp
is responsible for providing an interface to each registered service, health checks, and optionally documentation. The trivial app ModelApp([])
just exposes the health check endpoints /-/alive
and /-/ready
. An app that engages all available functionality might look like this:
from porter.services import ModelApp
app = ModelApp(
[service1, service2, ...],
name='Busy App',
description="""
<p>An app that exposes plenty of services.</p>
<p><b>Created by</b>: Jack and Jill</p>
""",
description='An app that exposes plenty of services',
version='1.0.37',
meta={'creators': 'Jack and Jill', 'release-date': '2020-04-01'},
expose_docs=True,
docs_url='/documentation/',
docs_json_url='/_documentation.json',
docs_prefix='/models/busy_app/')
At present, all keyword arguments to ModelApp()
are optional. Here are
their effects:
name
,description
,version
: These set the title, subtitle, and version badge at the top of the documentation. The description can optionally make use of HTML tags. Theporter
version will be appended to the description.meta
: This sets theapp_meta
object returned by the health checks (see Health Checks).expose_docs
: This enables automatic documentation.docs_url
: This determines the URI where the documentation is hosted; by default this is/docs/
. Note that GET requests to/
forward to this URI.docs_json_url
: This determines the URI for a JSON representation of the Swagger input; by default this is/_docs.json
. This can be useful for interfacing with other Swagger-related tools.docs_prefix
: This locates the documentation somewhere other than the root level. This is useful, for example, if the app will be deployed behind a load balancer. In this example, suppose Busy App is hosted at[domain]/models/busy_app/
; configuringdocs_prefix
allows the documentation to be served accordingly from[domain]/models/busy_app/documentation/
.
PredictionService¶
PredictionService
is the workhorse class for serving data science models. In Getting Started, we saw the minimal usage of PredictionService
:
from porter.services import PredictionService
prediction_service = PredictionService(
model=my_model,
name='my-model',
api_version='v1')
An instance that engages all available functionality might look like this:
prediction_service = PredictionService(
model=model,
name='supa-dupa-model',
api_version='v1',
meta={'creators': 'Alice & Bob'},
log_api_calls=True,
namespace='datascience',
action='prediction',
preprocessor=preprocessor,
postprocessor=postprocessor,
batch_prediction=False,
additional_checks=mychecks,
feature_schema=feature_schema,
prediction_schema=prediction_schema,
validate_request_data=True,
validate_response_data=True)
Here are the effects of the optional keyword arguments:
meta
: This sets themodel_meta
object that is returned as part of themodel_context
in POST responses.log_api_calls
: This enables logging; see Logging.namespace
,action
: These, along withname
andapi_version
, determine the prediction endpoint:/<namespace>/<name>/<api version>/<action>/
.preprocessor
,postprocessor
: These allow transformations to be made to the input and output, immediately before and aftermodel.predict()
. See example.py and thePredictionService()
docstring for more details.batch_prediction
: See Instance Prediction below.additional_checks
: Optional callable taking input DataFrameX
and raising aValueError
for invalid input. This is intended for input validation against complex constraints that cannot be expressed entirely usingfeature_schema
.feature_schema
,prediction_schema
,validate_request_data
,validate_response_data
: Input and output schemas for automatic validation and/or documentation. See also OpenAPI Schemas as well as Custom Prediction Schema below.
Instance Prediction¶
For models with expensive predictions, you may wish to enforce that prediction is run on individual instances at a time. For this behavior, request batch_prediction=False
, e.g.:
prediction_service = PredictionService(
model=my_model,
name='my-model',
api_version='v1',
batch_prediction=False)
Now the model will accept input of the form of a single object
{
"id": 1,
"user_id": 122333,
"title_id": 444455555,
"is_tv": true,
"genre": "comedy",
"average_rating": 6.7
}
as opposed to the usual array
:
[
{
"id": 1,
"user_id": 122333,
"title_id": 444455555,
"is_tv": true,
"genre": "comedy",
"average_rating": 6.7
}
]
Note
batch_prediction=False
does not fundamentally change the way porter
interacts with the underlying model object; it simply enforces that the input must include only a single object. Internally, the input is still converted into a pandas.DataFrame
with a single row. For a model which fundamentally accepts only a single object as an input, see Subclassing BaseService.
Custom Prediction Schema¶
By default, PredictionService
assumes that each prediction is a single scalar value, which can be represented by the following simple schema:
default_prediction_schema = porter.schemas.Number('Model Prediction')
However, custom models may return more complex outputs. For example, suppose we have a probabilistic model that returns lower and upper bounds in addition to an expected value. Here is an example model definition that doesn’t do anything but give us a working example:
import pandas as pd
import scipy.stats as ss
class ProbabilisticModel(BaseModel):
def predict(self, X):
dist = ss.norm(ss.norm(0, 1).rvs(len(X)), 1)
return pd.DataFrame({
'lower_bound': dist.ppf(0.05),
'expected_value': dist.mean(),
'upper_bound': dist.ppf(0.95),
}).to_dict(orient='records')
The predict()
method of this model accepts a DataFrame
and returns a list of dictionaries, one per input row. Output of this form is sufficient for yielding valid response JSON payloads with non-scalar predictions.
For automatically generating appropriate documentation for such a model, the per-row prediction schema could be described as:
proba_ratings_prediction_schema = Object(
'Return a prediction with upper and lower bounds',
properties={
'lower_bound': Number(
'Lower bound on the prediction. '
'Actual values should fall below this range just 5% of the time'),
'expected_value': Number(
'The average value we expect actual values to take.'),
'upper_bound': Number(
'Upper bound on the prediction. '
'Actual values should fall above this range just 95% of the time'),
},
reference_name='ProbaModelPrediction')
And the prediction service could be instantiated as:
probabilistic_service = PredictionService(
model=ProbabilisticRatingsModel(),
name='proba-model',
api_version='v1',
feature_schema=ratings_feature_schema,
prediction_schema=proba_ratings_prediction_schema)
In your own tests of probabilistic_service
, you can validate the response data by:
probabilistic_service.response_schema.validate(response)
Warning
There is also experimental support for automatic response validation: PredictionService(..., validate_response_data=True)
. Enabling this feature triggers a warning stating that it may increase response latency and produce confusing error messages for users. This should only be used for testing/debugging.
Subclassing BaseService¶
By subclassing BaseService
it is possible to expose arbitrary Python code. Consider complex input and output schemas such as:
from porter.schemas import Object, Array, String, Integer
custom_service_input = Object(
properties={
'string_with_enum_prop': String(additional_params={'enum': ['a', 'b', 'abc']}),
'an_array': Array(item_type=Number()),
'another_property': Object(properties={'a': String(), 'b': Integer()}),
'yet_another_property': Array(item_type=Object(additional_properties_type=String()))
},
reference_name='CustomServiceInputs'
)
custom_service_output_success = Object(
properties={
'request_id': request_id,
'model_context': model_context,
'results': Array(item_type=String())
}
)
A minimal app implementing and documenting this interface might look like:
from porter.services import BaseService, ModelApp
class CustomService(BaseService):
# action: required property or class attribute
action = 'custom-action'
# route_kwargs: required property or class attribute
route_kwargs = {'methods': ['POST']}
# status: required property
@property
def status(self):
return 'READY'
# serve: required method taking no arguments
def serve(self):
data = self.get_post_data()
return {'results': ['foo', 'bar']}
custom_service = CustomService(
name='custom-service',
api_version='v1',
validate_request_data=True)
custom_service.add_request_schema('POST', custom_service_input)
custom_service.add_response_schema('POST', 200, custom_service_output_success)
custom_app = ModelApp([custom_service], expose_docs=True)
This would expose an endpoint /custom-service/v1/custom-action
.
Note
Unlike PredictionService
, custom subclasses of BaseService
will receive POST data and deliver response data directly, with no automatic conversion to pandas.DataFrame
.
For a more complex example that serves calculations from a callable function, more closely matching the behavior of PredictionService
, see the function_service.py example script.