Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions HISTORY.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ Changelog
==========


15.0.0 (2026-08-14)
-------------------

* Initial release for DSS 15.0.0

14.7.3 (2026-08-03)
-------------------

Expand Down
6 changes: 6 additions & 0 deletions dataikuscoring/algorithms/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
from .forest_regressor import ForestRegressor
from .gradient_boosting_classifier import GradientBoostingClassifier
from .gradient_boosting_regressor import GradientBoostingRegressor
from .isolation_forest import IsolationForest
from .kmeans import KMeans
from .kmeans import MiniBatchKMeans
from .linear_regression import LinearRegressor
from .logistic import LogisticRegressionClassifier
from .mlp_classifier import MLPClassifer
Expand All @@ -15,6 +18,9 @@
"FOREST_REGRESSOR": ForestRegressor,
"GRADIENT_BOOSTING_CLASSIFIER": GradientBoostingClassifier,
"GRADIENT_BOOSTING_REGRESSOR": GradientBoostingRegressor,
"ISOLATION_FOREST": IsolationForest,
"KMEANS": KMeans,
"MINIBATCH_KMEANS": MiniBatchKMeans,
"LINEAR": LinearRegressor,
"LOGISTIC": LogisticRegressionClassifier,
"MLP_REGRESSOR": MLPRegressor,
Expand Down
15 changes: 15 additions & 0 deletions dataikuscoring/algorithms/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,18 @@ def __init__(self, model_parameters):
def predict(self, X):
"""Predict target vector from a 2D numpy array input X"""
raise NotImplementedError


class Clusterer:

def __init__(self, model_parameters):
"""The content of the dss_pipeline_model.gz file"""
raise NotImplementedError

def predict(self, X):
"""Predict the cluster index for each row of a 2D numpy array input X.

Anomaly-detection clusterers (e.g. Isolation Forest) additionally expose decision_function(X)
returning the per-row anomaly score.
"""
raise NotImplementedError
13 changes: 10 additions & 3 deletions dataikuscoring/algorithms/decision_tree_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ class Node:

def __init__(self, feature_idx=None, threshold=np.nan, left_child=None, right_child=None, label=None,
is_leaf=None, missing_goes_left=None, missing_value=np.nan, split_kind=SPLIT_KIND_THRESHOLD,
category_set=None):
category_set=None, n_node_samples=None):
self.label = label
self.feature_idx = feature_idx
self.threshold = threshold
Expand All @@ -70,6 +70,8 @@ def __init__(self, feature_idx=None, threshold=np.nan, left_child=None, right_ch
self.missing_value = missing_value
self.split_kind = split_kind
self.category_set = None if category_set is None else frozenset(float(v) for v in category_set)
# only populated for Isolation Forest leaves (used by its path-length anomaly score); None otherwise
self.n_node_samples = n_node_samples

def is_missing(self, data):
if np.isnan(self.missing_value):
Expand Down Expand Up @@ -136,9 +138,14 @@ def init_tree(self, model_parameters):
missing_value = model_parameters.get("missing_value", np.nan)

convert_threshold = np.float32 if self.variant == "XGBOOST" else np.float64
# n_node_samples is present only for Isolation Forest trees; aligned with leaf_id when present
leaf_n_node_samples = model_parameters.get("n_node_samples")
if leaf_n_node_samples is None or len(leaf_n_node_samples) == 0:
leaf_n_node_samples = [None] * len(model_parameters["leaf_id"])
leaves = {
leaf_id: Node(label=label, is_leaf=True, missing_value=missing_value) for leaf_id, label in zip(
model_parameters["leaf_id"], model_parameters["label"])
leaf_id: Node(label=label, is_leaf=True, missing_value=missing_value, n_node_samples=n_node_samples)
for leaf_id, label, n_node_samples in zip(
model_parameters["leaf_id"], model_parameters["label"], leaf_n_node_samples)
}

missing = model_parameters.get("missing")
Expand Down
73 changes: 73 additions & 0 deletions dataikuscoring/algorithms/isolation_forest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import numpy as np

from .common import Clusterer
from .decision_tree_model import DecisionTreeModel, SPLIT_KIND_CATEGORY_SET


def _average_path_length(n):
"""c(n): expected path length of an unsuccessful search in a binary search tree (sklearn _average_path_length)."""
if n <= 1:
return 0.0
if n == 2:
return 1.0
return 2.0 * (np.log(n - 1) + np.euler_gamma) - 2.0 * (n - 1) / n


def _path_depth(root, data):
"""Walk a tree to its leaf using sklearn routing (<=, missing-goes-left), returning (edge count, leaf)."""
current = root
depth = 0
while not current.is_leaf:
if current.is_missing(data):
current = current.left_child if current.missing_goes_left else current.right_child
elif current.split_kind == SPLIT_KIND_CATEGORY_SET:
current = current.left_child if current.has_category(data) else current.right_child
elif data[current.feature_idx] <= current.threshold:
current = current.left_child
else:
current = current.right_child
depth += 1
return depth, current


class IsolationForest(Clusterer):
"""Anomaly-detection clusterer reproducing sklearn IsolationForest.

decision_function(X) returns the anomaly score (the doctor's anomaly_score): per tree the isolation path
is leaf_depth + c(n_node_samples@leaf), averaged over the trees, normalised by c(max_samples) into
2^(-mean/c(psi)); the returned score is -raw - offset (anomaly when < 0).

predict(X) returns the cluster index: 1 (anomaly) when the score is < 0, else 0 (regular) -- matching the
doctor's DkuIsolationForest.predict. The model layer maps these indices to the cluster names.
"""

def __init__(self, model_parameters):
self.trees = [DecisionTreeModel(tree_params) for tree_params in model_parameters["trees"]]
# feature subset each tree was scored on (sklearn scores tree i on X[:, estimators_features[i]])
self.estimators_features = model_parameters["estimators_features"]
self.max_samples = model_parameters["max_samples"]
self.offset = model_parameters["offset"]
self._normalizer = _average_path_length(self.max_samples)
self.feature_converter = self.trees[0].feature_converter

def predict(self, X):
# cluster index: 1 -> anomaly (score < 0), 0 -> regular
return [int(score < 0) for score in self._scores(X)]

def decision_function(self, X):
return self._scores(X)

def _scores(self, X):
return [self._score(data) for data in self.feature_converter(X)]

def _score(self, data):
total_path = 0.0
for tree, features in zip(self.trees, self.estimators_features):
depth, leaf = _path_depth(tree.root, data[features])
total_path += depth + _average_path_length(leaf.n_node_samples)
mean_path = total_path / len(self.trees)
raw_score = 2.0 ** (-mean_path / self._normalizer)
return -raw_score - self.offset

def __repr__(self):
return "IsolationForest(n_trees={})".format(len(self.trees))
38 changes: 38 additions & 0 deletions dataikuscoring/algorithms/kmeans.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import numpy as np

from .common import Clusterer


class KMeans(Clusterer):
"""Clusterer reproducing sklearn KMeans / MiniBatchKMeans.

predict(X) assigns each row to the nearest cluster center (argmin squared-euclidean distance) in the
preprocessed feature space and returns the cluster index; the model layer maps that index to the cluster
name ("cluster_0", "cluster_1", ...). Both algorithms are fully defined by cluster_centers_, so one scorer
covers both. Unlike Isolation Forest there is no anomaly score, so no decision_function is exposed.
"""

def __init__(self, model_parameters):
# k x n_features; row j is the center of cluster j
self.cluster_centers = np.asarray(model_parameters["cluster_centers"], dtype=np.float64)
# ||c||^2 per center, precomputed for the distance expansion below
self._center_sq_norms = (self.cluster_centers ** 2).sum(axis=1)

def predict(self, X):
data = np.asarray(X, dtype=np.float64)
# nearest center by squared-euclidean distance via the expansion ||x - c||^2 = ||x||^2 - 2 x.c + ||c||^2.
# ||x||^2 is constant per row so it is dropped (it does not change the argmin); this keeps the cost
# O(n*k) in memory and lets the Java engine reproduce the exact same formula for cross-engine parity.
distances = -2.0 * data.dot(self.cluster_centers.T) + self._center_sq_norms
return [int(i) for i in np.argmin(distances, axis=1)]

def __repr__(self):
return "KMeans(n_clusters={})".format(len(self.cluster_centers))


class MiniBatchKMeans(KMeans):
"""MiniBatchKMeans scores identically to KMeans (nearest center); the distinct class just preserves the
model type through a serialize/reload round-trip."""

def __repr__(self):
return "MiniBatchKMeans(n_clusters={})".format(len(self.cluster_centers))
18 changes: 10 additions & 8 deletions dataikuscoring/algorithms/logistic.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,18 +36,20 @@ def multinomial_probabilities(dec):


def modified_huber_probabilities(dec):
p = 0.5 * (1 + np.minimum(1, np.maximum(-1, dec)))
dec = np.asarray(dec, dtype=float)
p = 0.5 * (1 + np.clip(dec, -1, 1))

if len(dec[0]) == 2:
if dec.shape[1] == 2:
p[:, 0] = 1 - p[:, 1]

norms = np.linalg.norm(dec, axis=1)
# scikit-learn normalizes the per-class values by their sum (Zadrozny & Elkan);
# rows whose values are all ~0 get a uniform distribution.
sums = p.sum(axis=1)
all_zero = sums < 1e-15
p[all_zero] = 1.0 / dec.shape[1]
sums[all_zero] = 1.0

# scikit-learn puts equal probas in this case
indexes = np.where(norms < 1e-15)
p[indexes] = np.ones(len(dec)) * (1 / len(dec))

return p / norms
return p / sums[:, None]


POLICIES = {
Expand Down
18 changes: 16 additions & 2 deletions dataikuscoring/load.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,15 @@ def load_resources_from_resource_folder(resources_folder):
user_meta_filename = os.path.join(resources_folder, "user_meta.json")
if os.path.isfile(user_meta_filename):
with open(user_meta_filename) as f:
resources["threshold"] = json.load(f).get("activeClassifierThreshold", 0.5)
user_meta = json.load(f)
resources["threshold"] = user_meta.get("activeClassifierThreshold", 0.5)
# User cluster renames (intrinsic cluster name -> user-chosen name). Mirrors the DSS python and java
# engines (reg_scoring_recipe / Build.remapClusterNames) so optimized scoring returns the same
# cluster_labels after a rename. Empty/absent for non-clustering models.
resources["cluster_name_map"] = {
cluster_id: cluster_data["name"]
for cluster_id, cluster_data in user_meta.get("clusterMetas", {}).items()
}

return resources

Expand Down Expand Up @@ -247,11 +255,17 @@ def create_model(resources):
algorithm_name = "MLP_REGRESSOR"
else:
algorithm_name = "MLP_CLASSIFIER"
# Apply user cluster renames to the intrinsic cluster names, mirroring the DSS python/java engines.
# No-op for non-clustering models (cluster_name_map is empty).
classes = resources["meta"].get("classes")
cluster_name_map = resources.get("cluster_name_map")
if classes is not None and cluster_name_map:
classes = [cluster_name_map.get(name, name) for name in classes]
parameters = {
"prepare_input": PrepareInput(resources),
"algorithm": ALGORITHMS[algorithm_name](dict({"missing_value": resources["missing_value"]}, **resources["model_parameters"])),
"preprocessings": Preprocessings(resources),
"classes": resources["meta"].get("classes"),
"classes": classes,
"calibration": Calibrator(resources),
"drop_rows": DropRows(resources)
}
Expand Down
6 changes: 3 additions & 3 deletions dataikuscoring/mlflow/classification.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ def mlflow_classification_predict_to_scoring_data(mlflow_model, imported_model_m
logger.info("MLflow outputs integers, converting")
preds = pd.Series(mlflow_raw_preds)
pred_df = pd.DataFrame({"prediction": mlflow_raw_preds})
pred_df["prediction"].replace(int_to_label_map, inplace=True)
pred_df["prediction"] = pred_df["prediction"].astype(object).replace(int_to_label_map)
elif (isinstance(first_value, float) or isinstance(first_value, np.floating)) and \
imported_model_meta["predictionType"] == "BINARY_CLASSIFICATION":
# only a column of floats ... probably prediction of class 1
Expand Down Expand Up @@ -223,7 +223,7 @@ def mlflow_classification_predict_to_scoring_data(mlflow_model, imported_model_m
preds = (probas_one > threshold).astype(int)
pred_df = pd.DataFrame({"prediction": preds})
logger.debug("Computed pred df %s" % pred_df)
pred_df["prediction"].replace(int_to_label_map, inplace=True)
pred_df["prediction"] = pred_df["prediction"].astype(object).replace(int_to_label_map)
logger.info("Computed cleanpred df %s" % pred_df["prediction"].dtype)

try:
Expand All @@ -238,7 +238,7 @@ def mlflow_classification_predict_to_scoring_data(mlflow_model, imported_model_m
exception_with_cause.__cause__ = e
raise exception_with_cause

if probas is not None and np.isnan(probas.to_numpy()).any():
if probas is not None and np.isnan(probas.fillna(np.nan).to_numpy(dtype=float)).any():
raise Exception("MLflow model predicted NaN probabilities")

logger.debug("Final pred_df: %s " % pred_df)
Expand Down
1 change: 1 addition & 0 deletions dataikuscoring/mlflow/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import pandas as pd
import numpy as np


logger = logging.getLogger(__name__)

class DisableMLflowTypeEnforcement(object):
Expand Down
4 changes: 3 additions & 1 deletion dataikuscoring/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
from .regression import RegressionModel
from .binary import BinaryModel
from .multiclass import MulticlassModel
from .clustering import ClusteringModel
from .partitioned import ClassificationPartitionedModel, RegressionPartitionedModel
from .mlflow import MLflowModel

MODELS = {
"REGRESSION": RegressionModel,
"BINARY_PROBABILISTIC": BinaryModel,
"MULTICLASS_PROBABILISTIC": MulticlassModel
"MULTICLASS_PROBABILISTIC": MulticlassModel,
"CLUSTERING": ClusteringModel
}

PARTITIONED_MODELS = {
Expand Down
44 changes: 44 additions & 0 deletions dataikuscoring/models/clustering.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import numpy as np

from .common import PredictionModelMixin, check_input_data
from .model import BaseModel


class ClusteringModel(BaseModel, PredictionModelMixin):
"""Clustering model for optimized scoring.

Like the in-DSS doctor (and dataikuscoring's classification models), the primary output of predict(X) is the
cluster label name (e.g. "regular"/"anomalies"), obtained by mapping the algorithm's cluster index through
the serialized cluster names (``classes``). For anomaly-detection clusterers the underlying anomaly score is
available separately via decision_function(X).
"""

def __init__(self, prepare_input, preprocessings, algorithm, drop_rows, classes=None, **kwargs):
super(ClusteringModel, self).__init__(prepare_input, preprocessings, algorithm, drop_rows)
# cluster label names, e.g. ["regular", "anomalies"]; index i -> classes[i]. May be None for models
# serialized before cluster names were emitted, in which case predict() falls back to the raw index.
self.classes = classes

def _compute_predict(self, X):
X_processed, valid_rows_mask = self._compute_preprocessed(X)
y_pred = np.array([None] * len(X), dtype=object)
indices = self.algorithm.predict(X_processed)
if self.classes is not None:
y_pred[valid_rows_mask] = [self.classes[int(i)] for i in indices]
else:
y_pred[valid_rows_mask] = indices
return y_pred

def decision_function(self, X):
"""Per-row anomaly score (available for anomaly-detection clusterers such as Isolation Forest)."""
if not hasattr(self.algorithm, "decision_function"):
raise NotImplementedError(
"decision_function is only available for anomaly-detection clustering models")
check_input_data(X)
X_processed, valid_rows_mask = self._compute_preprocessed(X)
scores = np.full(len(X), np.nan)
scores[valid_rows_mask] = self.algorithm.decision_function(X_processed)
return scores

def __repr__(self):
return "{} Clusterer".format(self.algorithm)
Loading