Skip to content

Commit 6dacb34

Browse files
authored
Merge pull request #2787 from oracle-devrel/oci-sdk-deployment
Initial commit for customizing ads deployment and changes for your-fi…
2 parents c3405af + 4c74b28 commit 6dacb34

File tree

9 files changed

+822
-0
lines changed

9 files changed

+822
-0
lines changed
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
Copyright (c) 2026 Oracle and/or its affiliates.
2+
3+
The Universal Permissive License (UPL), Version 1.0
4+
5+
Subject to the condition set forth below, permission is hereby granted to any
6+
person obtaining a copy of this software, associated documentation and/or data
7+
(collectively the "Software"), free of charge and under any and all copyright
8+
rights in the Software, and any and all patent rights owned or freely
9+
licensable by each licensor hereunder covering either (i) the unmodified
10+
Software as contributed to or provided by such licensor, or (ii) the Larger
11+
Works (as defined below), to deal in both
12+
13+
(a) the Software, and
14+
(b) any piece of software and/or hardware listed in the lrgrwrks.txt file if
15+
one is included with the Software (each a "Larger Work" to which the Software
16+
is contributed by such licensors),
17+
18+
without restriction, including without limitation the rights to copy, create
19+
derivative works of, display, perform, and distribute the Software and make,
20+
use, sell, offer for sale, import, export, have made, and have sold the
21+
Software and the Larger Work(s), and to sublicense the foregoing rights on
22+
either these or other terms.
23+
24+
This license is subject to the following condition:
25+
The above copyright notice and either this complete permission notice or at
26+
a minimum a reference to the UPL must be included in all copies or
27+
substantial portions of the Software.
28+
29+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
30+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
31+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
32+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
33+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
34+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
35+
SOFTWARE.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Overview
2+
3+
Reviewed: 2026.04.16
4+
5+
This project demonstrates how to deploy a machine learning model using the ADS SDK while customizing the default generated model artifacts, which is often required in production scenarios.
6+
7+
While ADS provides a standard template for model artifacts, real-world use cases frequently require additional logic. In this notebook, we focus on modifying the generated artifacts to incorporate feature engineering directly into the deployment pipeline.
8+
9+
The main advantage of this approach is that feature engineering is executed as part of the model inference process, eliminating the need to repeat these steps each time the model is invoked.
10+
11+
Specifically, the notebook covers:
12+
1. Building a model to predict Titanic survival using a Scikit-learn pipeline
13+
2. Preparing model artifacts using the ADS SDK
14+
3. Customizing the generated artifact to include feature engineering in score.py
15+
4. Registering, deploying, and invoking the model
16+
17+
# Environment
18+
19+
Conda environment: generalml_p311_cpu_x86_64_v1
20+
Created: April 2026
21+
22+
# Prerequisites
23+
- Access to OCI Data Science
24+
- Required IAM permissions for model registration and deployment
25+
- Basic familiarity with Python, Pandas, and Scikit-learn
26+
27+
# License
28+
Copyright (c) 2026 Oracle and/or its affiliates.
29+
Licensed under the Universal Permissive License (UPL), Version 1.0.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
2+
MODEL_ARTIFACT_VERSION: '3.0'
3+
MODEL_DEPLOYMENT:
4+
INFERENCE_CONDA_ENV:
5+
INFERENCE_ENV_PATH: 'oci://pub-conda-env@<your_name_space>/conda_environments/cpu/General Machine Learning for CPUs on Python 3.11/1.0/generalml_p311_cpu_x86_64_v1'
6+
INFERENCE_ENV_SLUG: ''
7+
INFERENCE_ENV_TYPE: published
8+
INFERENCE_PYTHON_VERSION: '3.11'
9+
MODEL_PROVENANCE:
10+
PROJECT_OCID: ''
11+
TENANCY_OCID: ''
12+
TRAINING_CODE:
13+
ARTIFACT_DIRECTORY: /home/datascience/custom_ads/sklearn_artifact_dir5
14+
TRAINING_COMPARTMENT_OCID: ''
15+
TRAINING_CONDA_ENV:
16+
TRAINING_ENV_PATH: ''
17+
TRAINING_ENV_SLUG: ''
18+
TRAINING_ENV_TYPE: ''
19+
TRAINING_PYTHON_VERSION: ''
20+
TRAINING_REGION: ''
21+
TRAINING_RESOURCE_OCID: ''
22+
USER_OCID: ''
23+
VM_IMAGE_INTERNAL_ID: ''
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
# score.py 1.0 generated by ADS 2.11.19 on 20260415_092331
2+
import json
3+
import os
4+
import cloudpickle
5+
import pandas as pd
6+
import numpy as np
7+
from functools import lru_cache
8+
9+
10+
model_name = 'model.pkl'
11+
12+
13+
"""
14+
Inference script. This script is used for prediction by scoring server when schema is known.
15+
"""
16+
17+
def create_features(df: pd.DataFrame) -> pd.DataFrame:
18+
data = df.copy()
19+
if {"sibsp", "parch"}.issubset(data.columns):
20+
data["family_size"] = data["sibsp"].fillna(0) + data["parch"].fillna(0) + 1
21+
data["is_alone"] = (data["family_size"] == 1).astype(int)
22+
for col in ["age", "fare", "embarked"]:
23+
if col in data.columns:
24+
data[f"{col}_missing"] = data[col].isna().astype(int)
25+
return data
26+
27+
28+
@lru_cache(maxsize=10)
29+
def load_model(model_file_name=model_name):
30+
"""
31+
Loads model from the serialized format
32+
33+
Returns
34+
-------
35+
model: a model instance on which predict API can be invoked
36+
"""
37+
model_dir = os.path.dirname(os.path.realpath(__file__))
38+
contents = os.listdir(model_dir)
39+
if model_file_name in contents:
40+
print(f'Start loading {model_file_name} from model directory {model_dir} ...')
41+
with open(os.path.join(os.path.dirname(os.path.realpath(__file__)), model_file_name), "rb") as file:
42+
loaded_model = cloudpickle.load(file)
43+
44+
print("Model is successfully loaded.")
45+
return loaded_model
46+
else:
47+
raise Exception(f'{model_file_name} is not found in model directory {model_dir}')
48+
49+
@lru_cache(maxsize=1)
50+
def fetch_data_type_from_schema(input_schema_path=os.path.join(os.path.dirname(os.path.realpath(__file__)), "input_schema.json")):
51+
"""
52+
Returns data type information fetch from input_schema.json.
53+
54+
Parameters
55+
----------
56+
input_schema_path: path of input schema.
57+
58+
Returns
59+
-------
60+
data_type: data type fetch from input_schema.json.
61+
62+
"""
63+
data_type = {}
64+
if os.path.exists(input_schema_path):
65+
schema = json.load(open(input_schema_path))
66+
for col in schema['schema']:
67+
data_type[col['name']] = col['dtype']
68+
else:
69+
print("input_schema has to be passed in in order to recover the same data type. pass `X_sample` in `ads.model.framework.sklearn_model.SklearnModel.prepare` function to generate the input_schema. Otherwise, the data type might be changed after serialization/deserialization.")
70+
return data_type
71+
72+
def deserialize(data, input_schema_path):
73+
"""
74+
Deserialize json serialization data to data in original type when sent to predict.
75+
76+
Parameters
77+
----------
78+
data: serialized input data.
79+
input_schema_path: path of input schema.
80+
81+
Returns
82+
-------
83+
data: deserialized input data.
84+
85+
"""
86+
87+
import base64
88+
from io import BytesIO, StringIO # added StringIO
89+
if isinstance(data, bytes):
90+
return data
91+
92+
data_type = data.get('data_type', '') if isinstance(data, dict) else ''
93+
json_data = data.get('data', data) if isinstance(data, dict) else data
94+
95+
if "numpy.ndarray" in data_type:
96+
load_bytes = BytesIO(base64.b64decode(json_data.encode('utf-8')))
97+
return np.load(load_bytes, allow_pickle=True)
98+
if "pandas.core.series.Series" in data_type:
99+
return pd.Series(json_data)
100+
if "pandas.core.frame.DataFrame" in data_type or isinstance(json_data, str):
101+
return pd.read_json(StringIO(json_data), dtype=fetch_data_type_from_schema(input_schema_path)) # add StringIO for better practice
102+
if isinstance(json_data, dict):
103+
return pd.DataFrame.from_dict(json_data)
104+
return json_data
105+
106+
107+
def pre_inference(data, input_schema_path):
108+
"""
109+
Preprocess data
110+
111+
Parameters
112+
----------
113+
data: Data format as expected by the predict API of the core estimator.
114+
input_schema_path: path of input schema.
115+
116+
Returns
117+
-------
118+
data: Data format after any processing.
119+
120+
"""
121+
return deserialize(data, input_schema_path)
122+
123+
def post_inference(yhat):
124+
"""
125+
Post-process the model results
126+
127+
Parameters
128+
----------
129+
yhat: Data format after calling model.predict.
130+
131+
Returns
132+
-------
133+
yhat: Data format after any processing.
134+
135+
"""
136+
if isinstance(yhat, pd.core.frame.DataFrame):
137+
yhat = yhat.values
138+
if isinstance(yhat, np.ndarray):
139+
yhat = yhat.tolist()
140+
return yhat
141+
142+
def predict(data, model=load_model(), input_schema_path=os.path.join(os.path.dirname(os.path.realpath(__file__)), "input_schema.json")):
143+
"""
144+
Returns prediction given the model and data to predict
145+
146+
Parameters
147+
----------
148+
model: Model instance returned by load_model API.
149+
data: Data format as expected by the predict API of the core estimator. For eg. in case of sckit models it could be numpy array/List of list/Pandas DataFrame.
150+
input_schema_path: path of input schema.
151+
152+
Returns
153+
-------
154+
predictions: Output from scoring server
155+
Format: {'prediction': output from model.predict method}
156+
157+
"""
158+
features = pre_inference(data, input_schema_path)
159+
features = create_features(features) # added to apply the customization
160+
yhat = post_inference(
161+
model.predict(features)
162+
)
163+
return {'prediction': yhat}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
from sklearn.ensemble import RandomForestClassifier
2+
from sklearn.metrics import classification_report, confusion_matrix, roc_auc_score, make_scorer, f1_score
3+
from sklearn.pipeline import Pipeline
4+
from sklearn.model_selection import RandomizedSearchCV
5+
import numpy as np
6+
7+
8+
def build_model(random_state: int = 42):
9+
"""Random Forest classifier - base estimator for HPO."""
10+
return RandomForestClassifier(
11+
random_state=random_state,
12+
n_jobs=-1
13+
)
14+
15+
16+
def build_pipeline(preprocessor, model):
17+
"""Attach preprocessing and model in one sklearn Pipeline."""
18+
return Pipeline(
19+
steps=[
20+
("preprocess", preprocessor),
21+
("model", model),
22+
]
23+
)
24+
25+
26+
def optimize_hyperparameters(pipeline, X_train, y_train, random_state: int = 42):
27+
"""
28+
Run RandomizedSearchCV optimizing for F1 on the minority class (label=1).
29+
Returns the best fitted pipeline.
30+
"""
31+
param_dist = {
32+
"model__n_estimators": [100, 200, 300, 500],
33+
"model__max_depth": [10, 15, 20, 30, None],
34+
"model__min_samples_split": [2, 5, 10, 20],
35+
"model__min_samples_leaf": [1, 2, 4, 8],
36+
"model__max_features": ["sqrt", "log2", 0.3, 0.5],
37+
"model__class_weight": [
38+
"balanced",
39+
"balanced_subsample",
40+
{0: 1, 1: 2}, # penalize missing class 1 twice as much
41+
{0: 1, 1: 3}, # penalize missing class 1 three times as much
42+
],
43+
}
44+
45+
# Optimize for F1 on minority class specifically
46+
scorer = make_scorer(f1_score, pos_label=1)
47+
48+
search = RandomizedSearchCV(
49+
estimator=pipeline,
50+
param_distributions=param_dist,
51+
n_iter=30, # number of parameter combinations to try
52+
scoring=scorer,
53+
cv=5, # 5-fold cross validation
54+
verbose=2,
55+
random_state=random_state,
56+
n_jobs=-1
57+
)
58+
59+
search.fit(X_train, y_train)
60+
61+
print(f"\nBest F1 (class 1): {search.best_score_:.4f}")
62+
print(f"Best params: {search.best_params_}")
63+
64+
return search.best_estimator_
65+
66+
67+
def evaluate_model(pipeline, X_test, y_test):
68+
"""Return key evaluation artifacts for test data."""
69+
y_pred = pipeline.predict(X_test)
70+
y_prob = pipeline.predict_proba(X_test)[:, 1]
71+
72+
report = classification_report(y_test, y_pred)
73+
cm = confusion_matrix(y_test, y_pred)
74+
auc = roc_auc_score(y_test, y_prob)
75+
76+
return {
77+
"classification_report": report,
78+
"confusion_matrix": cm,
79+
"roc_auc": auc,
80+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import pandas as pd
2+
from sklearn.compose import ColumnTransformer
3+
from sklearn.preprocessing import OneHotEncoder
4+
from category_encoders import TargetEncoder
5+
6+
7+
def create_features(df: pd.DataFrame) -> pd.DataFrame:
8+
"""Create a few simple Titanic features for demo purposes."""
9+
data = df.copy()
10+
11+
if {"sibsp", "parch"}.issubset(data.columns):
12+
data["family_size"] = data["sibsp"].fillna(0) + data["parch"].fillna(0) + 1
13+
data["is_alone"] = (data["family_size"] == 1).astype(int)
14+
15+
for col in ["age", "fare", "embarked"]:
16+
if col in data.columns:
17+
data[f"{col}_missing"] = data[col].isna().astype(int)
18+
19+
return data
20+
21+
22+
def split_column_types(
23+
X: pd.DataFrame,
24+
low_cardinality_threshold: int = 10,
25+
):
26+
"""Split columns to numeric / low-card categorical / high-card categorical."""
27+
numeric_cols = X.select_dtypes(include=["number", "bool"]).columns.tolist()
28+
categorical_cols = X.select_dtypes(include=["object", "category"]).columns.tolist()
29+
30+
low_card = [c for c in categorical_cols if X[c].nunique(dropna=True) <= low_cardinality_threshold]
31+
high_card = [c for c in categorical_cols if c not in low_card]
32+
33+
return numeric_cols, low_card, high_card
34+
35+
36+
def build_preprocessor(numeric_cols, low_card_cols, high_card_cols):
37+
"""Build ColumnTransformer with OneHot + TargetEncoder + numeric passthrough."""
38+
return ColumnTransformer(
39+
transformers=[
40+
("onehot", OneHotEncoder(handle_unknown="ignore"), low_card_cols),
41+
("target", TargetEncoder(handle_unknown="value", handle_missing="value"), high_card_cols),
42+
("num", "passthrough", numeric_cols),
43+
],
44+
remainder="drop",
45+
)

0 commit comments

Comments
 (0)