pycreditools 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,34 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+ workflow_dispatch:
7
+
8
+ jobs:
9
+ pypi-publish:
10
+ name: Build and publish to PyPI
11
+ runs-on: ubuntu-latest
12
+ environment:
13
+ name: pypi
14
+ url: https://pypi.org/p/pycreditools
15
+ permissions:
16
+ id-token: write # IMPORTANT: this permission is mandatory for trusted publishing
17
+ contents: read
18
+ steps:
19
+ - name: Checkout repository
20
+ uses: actions/checkout@v4
21
+
22
+ - name: Set up Python
23
+ uses: actions/setup-python@v5
24
+ with:
25
+ python-version: "3.10"
26
+
27
+ - name: Install pypa/build
28
+ run: python -m pip install build
29
+
30
+ - name: Build a binary wheel and a source tarball
31
+ run: python -m build
32
+
33
+ - name: Publish package distributions to PyPI
34
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,38 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ env/
8
+ build/
9
+ develop-eggs/
10
+ dist/
11
+ downloads/
12
+ eggs/
13
+ .eggs/
14
+ lib/
15
+ lib64/
16
+ parts/
17
+ sdist/
18
+ var/
19
+ wheels/
20
+ *.egg-info/
21
+ .installed.cfg
22
+ *.egg
23
+
24
+ # Pytest
25
+ .pytest_cache/
26
+
27
+ # Virtual Environments
28
+ venv/
29
+ .venv/
30
+ env/
31
+ ENV/
32
+ env.bak/
33
+ venv.bak/
34
+
35
+ # IDE
36
+ .idea/
37
+ .vscode/
38
+ *.swp
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Matheus Pasche
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,155 @@
1
+ Metadata-Version: 2.4
2
+ Name: pycreditools
3
+ Version: 0.1.0
4
+ Summary: Credit Risk Simulation and Policy Optimization — Python edition of creditools
5
+ Project-URL: Homepage, https://github.com/matheuspasche/pycreditools
6
+ Project-URL: Repository, https://github.com/matheuspasche/pycreditools
7
+ Project-URL: Issues, https://github.com/matheuspasche/pycreditools/issues
8
+ Author-email: Matheus Pasche <matheuspasche@outlook.com>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: credit-risk,credit-scoring,information-value,policy-optimization,risk-management,simulation,ward-clustering
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Financial and Insurance Industry
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Office/Business :: Financial
22
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.10
25
+ Requires-Dist: numpy>=1.24
26
+ Requires-Dist: pandas>=2.0
27
+ Provides-Extra: accel
28
+ Requires-Dist: numba>=0.58; extra == 'accel'
29
+ Provides-Extra: all
30
+ Requires-Dist: joblib>=1.3; extra == 'all'
31
+ Requires-Dist: numba>=0.58; extra == 'all'
32
+ Requires-Dist: plotly>=5.0; extra == 'all'
33
+ Provides-Extra: dev
34
+ Requires-Dist: pytest-cov>=4.0; extra == 'dev'
35
+ Requires-Dist: pytest>=7.0; extra == 'dev'
36
+ Requires-Dist: ruff>=0.4; extra == 'dev'
37
+ Provides-Extra: parallel
38
+ Requires-Dist: joblib>=1.3; extra == 'parallel'
39
+ Provides-Extra: viz
40
+ Requires-Dist: plotly>=5.0; extra == 'viz'
41
+ Description-Content-Type: text/markdown
42
+
43
+ <div align="center">
44
+ <h1>📊 PyCrediTools</h1>
45
+ <p><i>Credit Risk Simulation and Policy Optimization for Python</i></p>
46
+
47
+ [![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/)
48
+ [![License](https://img.shields.io/badge/license-MIT-green.svg)](https://opensource.org/licenses/MIT)
49
+ [![Status](https://img.shields.io/badge/status-alpha-orange.svg)]()
50
+ </div>
51
+
52
+ ---
53
+
54
+ **PyCrediTools** é uma biblioteca de ponta projetada para equipes de Risco de Crédito. Traduzida e evoluída a partir do pacote fundacional em R, ela fornece motores computacionais para **Simulação de Funis de Crédito (Trade-off de Políticas)** e **Agrupamento Autónomo de Risco (Risk Clustering)**.
55
+
56
+ Esqueça as aproximações por tentativas e erros. Com o PyCrediTools, você pode testar cortes de score contra taxas de aprovação, encontrar a política que maximiza a receita mantendo a inadimplência dentro do apetite ao risco, e recriar dinamicamente as faixas de Rating de forma matematicamente ótima.
57
+
58
+ ---
59
+
60
+ ## 🚀 Instalação
61
+
62
+ Atualmente em fase final de testes, o pacote pode ser instalado diretamente do GitHub:
63
+
64
+ ```bash
65
+ pip install git+https://github.com/matheuspasche/pycreditools.git
66
+ ```
67
+
68
+ *(Em breve estará disponível no PyPI via `pip install pycreditools`)*
69
+
70
+ ---
71
+
72
+ ## 💡 Core Features
73
+
74
+ - **Credit Policy Simulation**: Monte estágios rigorosos (Filtros duros, Regras de Corte de Score, Probabilidades Variáveis) e estresse a carteira sob diferentes condições económicas (agravamentos macro, declínios monotónicos).
75
+ - **Automated Risk Clustering**: Agrupe milhares de combinações de scores numa arquitetura compacta de "Ratings de Risco". O algoritmo respeita limitações de negócio rigorosas (Tolerância a inversão de safra, Exigência Mínima de Volume).
76
+ - **Distance Linkage Engine**: (Novo!) Uma evolução do algoritmo Ward tradicional que prioriza a simetria orgânica e o distanciamento da probabilidade de inadimplência em vez da densidade volumétrica da carteira.
77
+
78
+ ---
79
+
80
+ ## 📖 Quickstart (Exemplo de Uso)
81
+
82
+ O uso típico envolve duas fases: Simular a política para gerar a "População Aprovada", e depois agrupar essa população em Ratings estruturais.
83
+
84
+ ### 1. Simulação do Funil
85
+ ```python
86
+ import pandas as pd
87
+ from pycreditools.policy import CreditPolicy
88
+ from pycreditools.stages import CutoffStage
89
+ from pycreditools.simulation import simulate_policy
90
+
91
+ # Carregar Dados (O seu histórico de propostas e performance real)
92
+ df = pd.read_csv("minha_base.csv")
93
+
94
+ # Criar a Política
95
+ policy = (
96
+ CreditPolicy(score_cols=["meu_score_novo"], actual_default_col="inadimplencia")
97
+ .add_stage(CutoffStage("Aprovacao_Score", cutoffs={"meu_score_novo": 650}))
98
+ )
99
+
100
+ # Simular aprovação
101
+ df_simulado = simulate_policy(df, policy)
102
+ df_aprovados = df_simulado[df_simulado["_approved"]]
103
+ ```
104
+
105
+ ### 2. Agrupamento Ótimo de Risco (Clustering)
106
+ Vamos pedir ao motor que encontre o número **ótimo** de curvas de risco (até um máximo de 5 grupos), garantindo que **nunca se cruzam no tempo** (`max_crossings=0`).
107
+
108
+ ```python
109
+ from pycreditools.grouping import find_risk_groups
110
+
111
+ clustering = find_risk_groups(
112
+ df_aprovados,
113
+ score_cols="meu_score_novo",
114
+ default_col="inadimplencia",
115
+ time_col="safra_mes", # Para matriz de temporalidade
116
+ bins=20, # Granularidade da pesquisa
117
+ max_groups=5, # Teto Máximo
118
+ method="distance", # Heurística pura de Distância
119
+ max_crossings=0, # Tolerância Zero a inversão de curvas
120
+ min_vol_ratio=0.05 # Cada Rating deve ter >5% do volume
121
+ )
122
+
123
+ # Aplicar o modelo (nova coluna "risk_rating" gerada)
124
+ df_final = clustering.predict(df_aprovados)
125
+
126
+ print(f"O algoritmo agrupou os scores em {clustering.n_groups} Ratings de Risco Perfeitos.")
127
+ print(df_final.groupby("risk_rating")["inadimplencia"].mean())
128
+ ```
129
+
130
+ ---
131
+
132
+ ## 🧠 Algoritmos de Agrupamento
133
+
134
+ Ao invocar o `find_risk_groups`, o motor aceita dois métodos principais (`method="ward"` ou `method="distance"`):
135
+
136
+ ### Ward Method Tradicional (`method="ward"`)
137
+ Pesquisa aglomerativa que funde micro-faixas usando o critério de variância espacial (Ward). Este método tende a produzir faixas de risco **igualmente densas** em termos de volume (Ratings com 20% do volume cada, mesmo que o risco não esteja bem distribuído).
138
+
139
+ ### Distance Linkage Autónomo (`method="distance"`)
140
+ Um critério de custo inovador que ignora o volume na hora de medir as pontes matemáticas, penalizando unicamente o `(Risco 1 - Risco 2)^2`. A consequência brilhante disto é que os Ratings finais ficam distribuídos pelas faixas de probabilidade com **distâncias perfeitamente equidistantes**, independentemente se um Rating ficar com 30% da carteira e outro com 8%. Ideal para mapas visuais limpos e estabilidade de risco orgânica.
141
+
142
+ ---
143
+
144
+ ## 🛠️ Contribuir e Desenvolver
145
+
146
+ Para correr a suite de testes e submeter pull requests:
147
+ ```bash
148
+ git clone https://github.com/matheuspasche/pycreditools.git
149
+ cd pycreditools
150
+ pip install -e .[dev]
151
+ pytest tests/
152
+ ```
153
+
154
+ ## 📜 Licença
155
+ Distribuído sob licença MIT. Desenvolvido para a engenharia financeira moderna.
@@ -0,0 +1,113 @@
1
+ <div align="center">
2
+ <h1>📊 PyCrediTools</h1>
3
+ <p><i>Credit Risk Simulation and Policy Optimization for Python</i></p>
4
+
5
+ [![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/)
6
+ [![License](https://img.shields.io/badge/license-MIT-green.svg)](https://opensource.org/licenses/MIT)
7
+ [![Status](https://img.shields.io/badge/status-alpha-orange.svg)]()
8
+ </div>
9
+
10
+ ---
11
+
12
+ **PyCrediTools** é uma biblioteca de ponta projetada para equipes de Risco de Crédito. Traduzida e evoluída a partir do pacote fundacional em R, ela fornece motores computacionais para **Simulação de Funis de Crédito (Trade-off de Políticas)** e **Agrupamento Autónomo de Risco (Risk Clustering)**.
13
+
14
+ Esqueça as aproximações por tentativas e erros. Com o PyCrediTools, você pode testar cortes de score contra taxas de aprovação, encontrar a política que maximiza a receita mantendo a inadimplência dentro do apetite ao risco, e recriar dinamicamente as faixas de Rating de forma matematicamente ótima.
15
+
16
+ ---
17
+
18
+ ## 🚀 Instalação
19
+
20
+ Atualmente em fase final de testes, o pacote pode ser instalado diretamente do GitHub:
21
+
22
+ ```bash
23
+ pip install git+https://github.com/matheuspasche/pycreditools.git
24
+ ```
25
+
26
+ *(Em breve estará disponível no PyPI via `pip install pycreditools`)*
27
+
28
+ ---
29
+
30
+ ## 💡 Core Features
31
+
32
+ - **Credit Policy Simulation**: Monte estágios rigorosos (Filtros duros, Regras de Corte de Score, Probabilidades Variáveis) e estresse a carteira sob diferentes condições económicas (agravamentos macro, declínios monotónicos).
33
+ - **Automated Risk Clustering**: Agrupe milhares de combinações de scores numa arquitetura compacta de "Ratings de Risco". O algoritmo respeita limitações de negócio rigorosas (Tolerância a inversão de safra, Exigência Mínima de Volume).
34
+ - **Distance Linkage Engine**: (Novo!) Uma evolução do algoritmo Ward tradicional que prioriza a simetria orgânica e o distanciamento da probabilidade de inadimplência em vez da densidade volumétrica da carteira.
35
+
36
+ ---
37
+
38
+ ## 📖 Quickstart (Exemplo de Uso)
39
+
40
+ O uso típico envolve duas fases: Simular a política para gerar a "População Aprovada", e depois agrupar essa população em Ratings estruturais.
41
+
42
+ ### 1. Simulação do Funil
43
+ ```python
44
+ import pandas as pd
45
+ from pycreditools.policy import CreditPolicy
46
+ from pycreditools.stages import CutoffStage
47
+ from pycreditools.simulation import simulate_policy
48
+
49
+ # Carregar Dados (O seu histórico de propostas e performance real)
50
+ df = pd.read_csv("minha_base.csv")
51
+
52
+ # Criar a Política
53
+ policy = (
54
+ CreditPolicy(score_cols=["meu_score_novo"], actual_default_col="inadimplencia")
55
+ .add_stage(CutoffStage("Aprovacao_Score", cutoffs={"meu_score_novo": 650}))
56
+ )
57
+
58
+ # Simular aprovação
59
+ df_simulado = simulate_policy(df, policy)
60
+ df_aprovados = df_simulado[df_simulado["_approved"]]
61
+ ```
62
+
63
+ ### 2. Agrupamento Ótimo de Risco (Clustering)
64
+ Vamos pedir ao motor que encontre o número **ótimo** de curvas de risco (até um máximo de 5 grupos), garantindo que **nunca se cruzam no tempo** (`max_crossings=0`).
65
+
66
+ ```python
67
+ from pycreditools.grouping import find_risk_groups
68
+
69
+ clustering = find_risk_groups(
70
+ df_aprovados,
71
+ score_cols="meu_score_novo",
72
+ default_col="inadimplencia",
73
+ time_col="safra_mes", # Para matriz de temporalidade
74
+ bins=20, # Granularidade da pesquisa
75
+ max_groups=5, # Teto Máximo
76
+ method="distance", # Heurística pura de Distância
77
+ max_crossings=0, # Tolerância Zero a inversão de curvas
78
+ min_vol_ratio=0.05 # Cada Rating deve ter >5% do volume
79
+ )
80
+
81
+ # Aplicar o modelo (nova coluna "risk_rating" gerada)
82
+ df_final = clustering.predict(df_aprovados)
83
+
84
+ print(f"O algoritmo agrupou os scores em {clustering.n_groups} Ratings de Risco Perfeitos.")
85
+ print(df_final.groupby("risk_rating")["inadimplencia"].mean())
86
+ ```
87
+
88
+ ---
89
+
90
+ ## 🧠 Algoritmos de Agrupamento
91
+
92
+ Ao invocar o `find_risk_groups`, o motor aceita dois métodos principais (`method="ward"` ou `method="distance"`):
93
+
94
+ ### Ward Method Tradicional (`method="ward"`)
95
+ Pesquisa aglomerativa que funde micro-faixas usando o critério de variância espacial (Ward). Este método tende a produzir faixas de risco **igualmente densas** em termos de volume (Ratings com 20% do volume cada, mesmo que o risco não esteja bem distribuído).
96
+
97
+ ### Distance Linkage Autónomo (`method="distance"`)
98
+ Um critério de custo inovador que ignora o volume na hora de medir as pontes matemáticas, penalizando unicamente o `(Risco 1 - Risco 2)^2`. A consequência brilhante disto é que os Ratings finais ficam distribuídos pelas faixas de probabilidade com **distâncias perfeitamente equidistantes**, independentemente se um Rating ficar com 30% da carteira e outro com 8%. Ideal para mapas visuais limpos e estabilidade de risco orgânica.
99
+
100
+ ---
101
+
102
+ ## 🛠️ Contribuir e Desenvolver
103
+
104
+ Para correr a suite de testes e submeter pull requests:
105
+ ```bash
106
+ git clone https://github.com/matheuspasche/pycreditools.git
107
+ cd pycreditools
108
+ pip install -e .[dev]
109
+ pytest tests/
110
+ ```
111
+
112
+ ## 📜 Licença
113
+ Distribuído sob licença MIT. Desenvolvido para a engenharia financeira moderna.
@@ -0,0 +1,75 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pycreditools"
7
+ version = "0.1.0"
8
+ description = "Credit Risk Simulation and Policy Optimization — Python edition of creditools"
9
+ readme = "README.md"
10
+ license = {text = "MIT"}
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ {name = "Matheus Pasche", email = "matheuspasche@outlook.com"},
14
+ ]
15
+ keywords = [
16
+ "credit-risk",
17
+ "simulation",
18
+ "policy-optimization",
19
+ "risk-management",
20
+ "ward-clustering",
21
+ "information-value",
22
+ "credit-scoring",
23
+ ]
24
+ classifiers = [
25
+ "Development Status :: 3 - Alpha",
26
+ "Intended Audience :: Financial and Insurance Industry",
27
+ "Intended Audience :: Science/Research",
28
+ "License :: OSI Approved :: MIT License",
29
+ "Programming Language :: Python :: 3",
30
+ "Programming Language :: Python :: 3.10",
31
+ "Programming Language :: Python :: 3.11",
32
+ "Programming Language :: Python :: 3.12",
33
+ "Programming Language :: Python :: 3.13",
34
+ "Topic :: Scientific/Engineering :: Information Analysis",
35
+ "Topic :: Office/Business :: Financial",
36
+ "Typing :: Typed",
37
+ ]
38
+ dependencies = [
39
+ "pandas>=2.0",
40
+ "numpy>=1.24",
41
+ ]
42
+
43
+ [project.optional-dependencies]
44
+ viz = ["plotly>=5.0"]
45
+ parallel = ["joblib>=1.3"]
46
+ accel = ["numba>=0.58"]
47
+ all = [
48
+ "plotly>=5.0",
49
+ "joblib>=1.3",
50
+ "numba>=0.58",
51
+ ]
52
+ dev = [
53
+ "pytest>=7.0",
54
+ "pytest-cov>=4.0",
55
+ "ruff>=0.4",
56
+ ]
57
+
58
+ [project.urls]
59
+ Homepage = "https://github.com/matheuspasche/pycreditools"
60
+ Repository = "https://github.com/matheuspasche/pycreditools"
61
+ Issues = "https://github.com/matheuspasche/pycreditools/issues"
62
+
63
+ [tool.hatch.build.targets.wheel]
64
+ packages = ["src/pycreditools"]
65
+
66
+ [tool.pytest.ini_options]
67
+ testpaths = ["tests"]
68
+ addopts = "-v --tb=short"
69
+
70
+ [tool.ruff]
71
+ target-version = "py310"
72
+ line-length = 100
73
+
74
+ [tool.ruff.lint]
75
+ select = ["E", "F", "I", "W", "UP"]
@@ -0,0 +1,43 @@
1
+ """
2
+ pycreditools: A Python library for credit risk policy simulation and analysis.
3
+ """
4
+
5
+ from ._types import SimulationMethod, ClusteringMethod, Quadrant, StageDirection, PolicySummary
6
+ from .stages import Stage, CutoffStage, FilterStage, RateStage
7
+ from .stress import StressScenario, AggravationStress, MonotonicStress, CustomStress
8
+ from .policy import CreditPolicy
9
+ from .simulation import CreditSimResults, run_simulation
10
+ from .performance import summarize_results, compare_policies
11
+ from .analysis import run_tradeoff_analysis
12
+ from .grouping import find_risk_groups, RiskGroupResult, GroupingRecipe
13
+ from .screening import screen_risk_segments, ScreeningResult, ScreeningRecipe
14
+ from .sample_data import generate_sample_data
15
+
16
+ __all__ = [
17
+ "SimulationMethod",
18
+ "ClusteringMethod",
19
+ "Quadrant",
20
+ "StageDirection",
21
+ "PolicySummary",
22
+ "Stage",
23
+ "CutoffStage",
24
+ "FilterStage",
25
+ "RateStage",
26
+ "StressScenario",
27
+ "AggravationStress",
28
+ "MonotonicStress",
29
+ "CustomStress",
30
+ "CreditPolicy",
31
+ "CreditSimResults",
32
+ "run_simulation",
33
+ "summarize_results",
34
+ "compare_policies",
35
+ "run_tradeoff_analysis",
36
+ "find_risk_groups",
37
+ "RiskGroupResult",
38
+ "GroupingRecipe",
39
+ "screen_risk_segments",
40
+ "ScreeningResult",
41
+ "ScreeningRecipe",
42
+ "generate_sample_data",
43
+ ]
@@ -0,0 +1,5 @@
1
+ from .ward import ward_cluster
2
+ from .iv import iv_cluster
3
+ from .tier_metrics import calculate_tier_metrics
4
+
5
+ __all__ = ["ward_cluster", "iv_cluster", "calculate_tier_metrics"]
@@ -0,0 +1,167 @@
1
+ from __future__ import annotations
2
+ import numpy as np
3
+
4
+ def iv_cluster(
5
+ pd_values: np.ndarray,
6
+ volumes: np.ndarray,
7
+ max_groups: int,
8
+ min_vol_ratio: float,
9
+ lambda_cross: float = 0.5,
10
+ lambda_vol: float = 0.2,
11
+ monthly_vols: np.ndarray | None = None,
12
+ monthly_bads: np.ndarray | None = None,
13
+ ) -> np.ndarray:
14
+ """
15
+ IV-based agglomerative clustering with constraints.
16
+
17
+ Args:
18
+ pd_values: float64[n_bins] - mean PD per bin
19
+ volumes: int64[n_bins] - volume per bin
20
+ max_groups: exact number of output clusters (algorithm will merge down to this)
21
+ min_vol_ratio: min fraction of total volume per cluster
22
+ lambda_cross: penalty weight for vintage crossings
23
+ lambda_vol: penalty weight for PD volatility
24
+ monthly_vols: int64[n_bins, n_months]
25
+ monthly_bads: int64[n_bins, n_months]
26
+
27
+ Returns:
28
+ int64[n_bins] - 1-based group assignments
29
+ """
30
+ n_bins = len(pd_values)
31
+ if n_bins == 0:
32
+ return np.array([], dtype=np.int64)
33
+ if n_bins <= max_groups and (volumes == 0).sum() == 0:
34
+ # Check if all other constraints hold? Actually if we just want to force merges
35
+ # when constraints are violated, we should still run the loop.
36
+ pass
37
+
38
+ active = np.ones(n_bins, dtype=bool)
39
+ current_vol = volumes.copy().astype(np.float64)
40
+ current_bads = (pd_values * current_vol).astype(np.float64)
41
+
42
+ total_vol = current_vol.sum()
43
+ total_bads = current_bads.sum()
44
+ total_goods = total_vol - total_bads
45
+
46
+ if monthly_vols is not None and monthly_bads is not None:
47
+ curr_m_vols = monthly_vols.copy().astype(np.float64)
48
+ curr_m_bads = monthly_bads.copy().astype(np.float64)
49
+ else:
50
+ curr_m_vols = None
51
+ curr_m_bads = None
52
+
53
+ group_ids = np.arange(n_bins)
54
+ n_active = n_bins
55
+
56
+ def calc_iv(bads, vols):
57
+ if total_goods <= 0 or total_bads <= 0:
58
+ return 0.0
59
+ goods = vols - bads
60
+ p_b = bads / total_bads
61
+ p_g = goods / total_goods
62
+ if p_b <= 0 or p_g <= 0:
63
+ return 0.0
64
+ return (p_g - p_b) * np.log(p_g / p_b)
65
+
66
+ while True:
67
+ if n_active <= 1:
68
+ break
69
+
70
+ active_indices = np.where(active)[0]
71
+ n_curr = len(active_indices)
72
+
73
+ min_cost = np.inf
74
+ best_merge_idx = -1
75
+
76
+ for i in range(n_curr - 1):
77
+ idx1 = active_indices[i]
78
+ idx2 = active_indices[i+1]
79
+
80
+ v1 = current_vol[idx1]
81
+ v2 = current_vol[idx2]
82
+ b1 = current_bads[idx1]
83
+ b2 = current_bads[idx2]
84
+
85
+ p1 = b1 / v1 if v1 > 0 else 0.0
86
+ p2 = b2 / v2 if v2 > 0 else 0.0
87
+
88
+ # Hard skip for monotonicity violation unless it's a forced merge
89
+ # Monotonicity violation: p1 >= p2
90
+ violation = (p1 >= p2) and (v1 > 0) and (v2 > 0)
91
+
92
+ # Force merges if volume is 0
93
+ if v1 == 0 or v2 == 0:
94
+ cost = -1e9
95
+ else:
96
+ if violation:
97
+ cost = -1e6 # prioritize fixing monotonicity over normal merges
98
+ else:
99
+ # Calculate IV loss
100
+ iv1 = calc_iv(b1, v1)
101
+ iv2 = calc_iv(b2, v2)
102
+ iv_merged = calc_iv(b1 + b2, v1 + v2)
103
+ iv_loss = iv1 + iv2 - iv_merged
104
+
105
+ cross_penalty = 0.0
106
+ volatility_penalty = 0.0
107
+
108
+ if curr_m_vols is not None and curr_m_bads is not None:
109
+ mv = curr_m_vols[idx1] + curr_m_vols[idx2]
110
+ mb = curr_m_bads[idx1] + curr_m_bads[idx2]
111
+
112
+ valid = mv > 0
113
+ if valid.any():
114
+ mp = mb[valid] / mv[valid]
115
+ volatility_penalty = np.std(mp)
116
+
117
+ # crossings between new merged group and neighbors?
118
+ # To simplify, the C++ IV clustering engine penalizes crossings
119
+ # *within* the merged group (i.e. did the two groups cross each other?)
120
+ mv1 = curr_m_vols[idx1]
121
+ mv2 = curr_m_vols[idx2]
122
+ mb1 = curr_m_bads[idx1]
123
+ mb2 = curr_m_bads[idx2]
124
+ v_valid = (mv1 > 0) & (mv2 > 0)
125
+ if v_valid.any():
126
+ mp1 = mb1[v_valid] / mv1[v_valid]
127
+ mp2 = mb2[v_valid] / mv2[v_valid]
128
+ crossings = np.sum(mp1 >= mp2)
129
+ cross_penalty = crossings
130
+
131
+ cost = iv_loss + lambda_cross * cross_penalty + lambda_vol * volatility_penalty
132
+
133
+ # Force merge if volume below threshold
134
+ if (v1 / total_vol < min_vol_ratio) or (v2 / total_vol < min_vol_ratio):
135
+ cost -= 1000.0 # arbitrary large priority but less than monotonicity
136
+
137
+ if cost < min_cost:
138
+ min_cost = cost
139
+ best_merge_idx = i
140
+
141
+ # Stopping condition
142
+ # If no forced merges are required AND we reached max_groups, stop.
143
+ # Forced merges have cost < -100
144
+ if min_cost >= -100 and n_active <= max_groups:
145
+ break
146
+
147
+ # Execute merge
148
+ idx1 = active_indices[best_merge_idx]
149
+ idx2 = active_indices[best_merge_idx + 1]
150
+
151
+ current_vol[idx1] += current_vol[idx2]
152
+ current_bads[idx1] += current_bads[idx2]
153
+
154
+ if curr_m_vols is not None and curr_m_bads is not None:
155
+ curr_m_vols[idx1] += curr_m_vols[idx2]
156
+ curr_m_bads[idx1] += curr_m_bads[idx2]
157
+
158
+ active[idx2] = False
159
+ group_ids[group_ids == idx2] = idx1
160
+ n_active -= 1
161
+
162
+ # Remap to 1-based sequential integers
163
+ active_indices = np.where(active)[0]
164
+ final_mapping = {old_idx: new_idx for new_idx, old_idx in enumerate(active_indices, 1)}
165
+
166
+ result = np.array([final_mapping[g] for g in group_ids], dtype=np.int64)
167
+ return result