Skip to content
Merged
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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ dynamic = [
[tool.setuptools.dynamic]
readme = {file = ["README.md"], content-type = "text/markdown"}
dependencies = {file = ["requirements.txt"]}
optional-dependencies = {nn = {file = ["requirements-nn.txt"]}, tools = {file = ["requirements-tools.txt"]}, all = {file = ["requirements-nn.txt", "requirements-tools.txt"]} }
optional-dependencies = {nn = {file = ["requirements-nn.txt"]}, tools = {file = ["requirements-tools.txt"]}, pmml = {file = ["requirements-pmml.txt"]}, all = {file = ["requirements-nn.txt", "requirements-tools.txt", "requirements-pmml.txt"]} }

[build-system]
requires = [
Expand Down
2 changes: 2 additions & 0 deletions requirements-pmml.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
sklearn2pmml >= 0.80
sklearn-pandas >= 2.0
125 changes: 125 additions & 0 deletions toad/scorecard.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,49 @@
FACTOR_UNKNOWN = 'UNKNOWN'


def _build_numeric_expression(split_points, scores, nan_score=None):
"""Build a nested if-else expression for ExpressionTransformer.

Args:
split_points (ndarray): split point values
scores (ndarray): scores array, length = len(split_points) + 1
nan_score (float|None): score for NaN values

Returns:
str: expression string for ExpressionTransformer
"""
n_splits = len(split_points)

if n_splits == 0:
s = str(float(scores[0]))
if nan_score is not None:
return f'{nan_score} if pandas.isnull(X[0]) else {s}'
return s

parts = []
closing = ''

if nan_score is not None:
parts.append(f'{nan_score} if pandas.isnull(X[0])')

for i in range(n_splits + 1):
score = float(scores[i])
if i == 0:
if parts:
parts.append(f' else ({score} if X[0] < {split_points[i]}')
closing += ')'
else:
parts.append(f'{score} if X[0] < {split_points[i]}')
elif i == n_splits:
parts.append(f' else {score}')
else:
parts.append(f' else ({score} if X[0] < {split_points[i]}')
closing += ')'

parts.append(closing)
return ''.join(parts)



class ScoreCard(BaseEstimator, RulesMixin, BinsMixin):
def __init__(self, pdo = 60, rate = 2, base_odds = 35, base_score = 750,
Expand Down Expand Up @@ -377,6 +420,88 @@ def after_export(self, card, to_frame = False, to_json = None, to_csv = None, **
return card


def card2pmml(self, pmml_path='scorecard.pmml', debug=False):
"""Export scorecard to PMML format.

Args:
pmml_path (str): path to write the PMML file
debug (bool): if True, print debug info from sklearn2pmml

Requires:
pip install toad[pmml] (sklearn2pmml >= 0.80, sklearn-pandas >= 2.0)
Java 11+ runtime
"""
if not self.rules:
raise RuntimeError(
"No scorecard rules found. Call fit() or load() before card2pmml()."
)

try:
from sklearn_pandas import DataFrameMapper
from sklearn.linear_model import LinearRegression
from sklearn2pmml import sklearn2pmml, PMMLPipeline
from sklearn2pmml.preprocessing import LookupTransformer, ExpressionTransformer
except ImportError as e:
raise ImportError(
"card2pmml requires 'sklearn2pmml' and 'sklearn-pandas'. "
"Install them with: pip install toad[pmml]"
) from e

mapper = []
for var, rule in self.rules.items():
bins = rule['bins']
scores = rule['scores']

if not np.issubdtype(bins.dtype, np.number):
# Categorical feature
mapping = {}
default_value = 0.0
for group, score in zip(bins, scores):
score_f = float(score)
if isinstance(group, str) and group == self.ELSE_GROUP:
default_value = score_f
elif isinstance(group, (list, np.ndarray)):
for val in group:
mapping[val] = score_f
else:
mapping[group] = score_f
mapper.append((
[var],
LookupTransformer(mapping=mapping, default_value=default_value),
))
else:
# Numeric feature
has_nan = len(bins) > 0 and np.isnan(bins[-1])
if has_nan:
split_points = bins[:-1]
split_scores = scores[:-1]
nan_score = float(scores[-1])
else:
split_points = bins
split_scores = scores
nan_score = None

expression = _build_numeric_expression(
split_points, split_scores, nan_score,
)
mapper.append(([var], ExpressionTransformer(expression)))

scorecard_mapper = DataFrameMapper(mapper, df_out=True)

feature_names = list(self.rules.keys())
n_features = len(feature_names)
lr = LinearRegression(fit_intercept=False)
lr.coef_ = np.ones(n_features)
lr.intercept_ = 0.0
lr.n_features_in_ = n_features
lr.feature_names_in_ = np.array(feature_names)

pipeline = PMMLPipeline([
('preprocessing', scorecard_mapper),
('scorecard', lr),
])
sklearn2pmml(pipeline, pmml_path, with_repr=True, debug=debug)


def _generate_testing_frame(self, maps, size = 'max', mishap = True, gap = 1e-2):
"""
Expand Down
93 changes: 92 additions & 1 deletion toad/scorecard_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import pandas as pd
from sklearn.linear_model import LogisticRegression

from .scorecard import ScoreCard, WOETransformer, Combiner
from .scorecard import ScoreCard, WOETransformer, Combiner, _build_numeric_expression

np.random.seed(1)

Expand Down Expand Up @@ -264,3 +264,94 @@ def test_predict_dict():
proba = card.predict(df.iloc[404].to_dict())
assert proba == TEST_SCORE


# --- _build_numeric_expression tests ---

def test_build_numeric_expression_basic():
expr = _build_numeric_expression(
np.array([3.0, 5.0, 8.0]),
np.array([100, 200, 300, 400]),
)
assert 'X[0] < 3.0' in expr
assert 'X[0] < 5.0' in expr
assert 'X[0] < 8.0' in expr
assert '100.0' in expr
assert '400.0' in expr
assert 'isnull' not in expr


def test_build_numeric_expression_with_nan():
expr = _build_numeric_expression(
np.array([3.0, 5.0]),
np.array([100, 200, 300]),
nan_score=500.0,
)
assert expr.startswith('500.0 if pandas.isnull(X[0])')
assert '100.0' in expr
assert '300.0' in expr


def test_build_numeric_expression_no_splits():
expr = _build_numeric_expression(np.array([]), np.array([42]))
assert expr == '42.0'


def test_build_numeric_expression_no_splits_with_nan():
expr = _build_numeric_expression(np.array([]), np.array([42]), nan_score=99.0)
assert '99.0' in expr
assert '42.0' in expr
assert 'isnull' in expr


def test_build_numeric_expression_single_split():
expr = _build_numeric_expression(np.array([5.0]), np.array([100, 200]))
assert 'X[0] < 5.0' in expr
assert '100.0' in expr
assert '200.0' in expr


# --- card2pmml tests ---

def test_card2pmml_missing_rules():
sc = ScoreCard()
with pytest.raises(RuntimeError, match='No scorecard rules'):
sc.card2pmml()


def test_card2pmml_import_error(monkeypatch):
"""Verify helpful ImportError when sklearn2pmml is missing."""
import builtins
real_import = builtins.__import__

def mock_import(name, *args, **kwargs):
if name == 'sklearn_pandas':
raise ImportError('No module')
return real_import(name, *args, **kwargs)

sc = ScoreCard().load(card_config)
monkeypatch.setattr(builtins, '__import__', mock_import)
with pytest.raises(ImportError, match='pip install toad\\[pmml\\]'):
sc.card2pmml()


@pytest.fixture
def pmml_deps():
pytest.importorskip('sklearn2pmml')
pytest.importorskip('sklearn_pandas')
import shutil
if shutil.which('java') is None:
pytest.skip('Java 11+ required')


def test_card2pmml_from_config(pmml_deps, tmp_path):
sc = ScoreCard().load(card_config)
out = str(tmp_path / 'test_config.pmml')
sc.card2pmml(out)
assert (tmp_path / 'test_config.pmml').stat().st_size > 0


def test_card2pmml_from_fitted(pmml_deps, tmp_path):
out = str(tmp_path / 'test_fitted.pmml')
card.card2pmml(out)
assert (tmp_path / 'test_fitted.pmml').stat().st_size > 0

Loading