feat(risk): bind the scoring engine to the ML service, with honest attribution
Adds MlClient and the two model-backed strategies behind the existing
ScoringStrategy / ForecastStrategy seams, so switching engines stays a config
value rather than a code change.
MlClient never throws. Every call returns Optional.empty() on any failure —
timeout, connection refused, malformed body — and ModelScoringStrategy falls
back to the rule engine for that enterprise. A nightly run must complete, and
a demo must survive the Python process dying mid-presentation.
The important part is what the fallback records. A run configured MODEL used
to stamp every score MODEL even when the ML service was down and rules had
actually produced the number, which made the provenance column a lie exactly
when it mattered. ScoreOutcome now carries an `engine` field and the fallback
returns .attributedTo("RULE_FALLBACK"). Verified by killing the service
mid-run: run 285 MODEL, run 286 RULE_FALLBACK, 260 of 263 scores differ.
ModelMetric and its read-only controller expose the measured backtest at
GET /admin/model/metrics. syntheticLabelShare is stored on the same row as
every metric so accuracy cannot be quoted without the caveat travelling with
it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
package com.lincfusion.udyamlinc.ml;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* The one call out to the Python inference service.
|
||||
*
|
||||
* <p><b>Never throws to the caller.</b> Every method returns an {@link Optional},
|
||||
* empty on any failure — connection refused, timeout, a 500, a malformed body.
|
||||
* That is deliberate: the caller is a scoring run, and a run that fails because
|
||||
* an inference service is unreachable is worse than a run that scores on rules
|
||||
* and says which engine it used. The failure is logged once per call, not per
|
||||
* enterprise, so a service being down does not produce 263 identical stack traces.
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class MlClient {
|
||||
|
||||
private final MlProperties properties;
|
||||
private final RestClient http;
|
||||
|
||||
public MlClient(MlProperties properties) {
|
||||
this.properties = properties;
|
||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||
factory.setConnectTimeout(Duration.ofMillis(properties.getTimeoutMs()));
|
||||
factory.setReadTimeout(Duration.ofMillis(properties.getBatchTimeoutMs()));
|
||||
this.http = RestClient.builder()
|
||||
.baseUrl(properties.getBaseUrl())
|
||||
.requestFactory(factory)
|
||||
.build();
|
||||
}
|
||||
|
||||
public boolean enabled() {
|
||||
return properties.isEnabled();
|
||||
}
|
||||
|
||||
/** What the service is serving — becomes {@code scoring_run.model_version}. */
|
||||
public Optional<Map<String, Object>> modelInfo() {
|
||||
return call(() -> http.get().uri("/model").retrieve().body(Map.class), "/model");
|
||||
}
|
||||
|
||||
public Optional<MlScore> score(Map<String, Object> request) {
|
||||
return call(() -> http.post().uri("/score").body(request).retrieve().body(MlScore.class), "/score");
|
||||
}
|
||||
|
||||
/** One call for the nightly run, rather than one per enterprise. */
|
||||
@SuppressWarnings("unchecked")
|
||||
public Optional<List<MlScore>> scoreBatch(List<Map<String, Object>> items) {
|
||||
return call(() -> http.post().uri("/score/batch")
|
||||
.body(Map.of("items", items))
|
||||
.retrieve()
|
||||
.body(List.class), "/score/batch");
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private <T> Optional<T> call(java.util.function.Supplier<Object> supplier, String what) {
|
||||
if (!properties.isEnabled()) return Optional.empty();
|
||||
try {
|
||||
return Optional.ofNullable((T) supplier.get());
|
||||
} catch (Exception e) {
|
||||
log.warn("ML service {} unavailable ({}) — falling back to the rule engine",
|
||||
what, e.getClass().getSimpleName());
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<MlForecast> forecast(Map<String, Object> request) {
|
||||
return call(() -> http.post().uri("/forecast").body(request).retrieve().body(MlForecast.class),
|
||||
"/forecast");
|
||||
}
|
||||
|
||||
/** Mirrors the FastAPI response, field for field. */
|
||||
public record MlScore(
|
||||
String enterpriseId,
|
||||
int score,
|
||||
String band,
|
||||
int confidence,
|
||||
double probability,
|
||||
Map<String, Integer> subScores,
|
||||
List<MlDriver> drivers,
|
||||
int familiesOnPrior,
|
||||
String engine,
|
||||
String modelVersion) {}
|
||||
|
||||
public record MlDriver(String family, int weight, String featureCode, Double value, boolean onPrior) {}
|
||||
|
||||
public record MlForecast(String enterpriseId, String engine, String modelVersion,
|
||||
List<MlForecastPoint> points) {}
|
||||
|
||||
public record MlForecastPoint(int horizon, double p10, double p50, double p90) {}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.lincfusion.udyamlinc.ml;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Where the inference service is, and how long we are willing to wait for it.
|
||||
*
|
||||
* <p>Timeouts are short on purpose. A scoring run over the whole book must finish
|
||||
* inside its window, and an inference service that has become slow is
|
||||
* operationally the same as one that is down — both mean "score this enterprise
|
||||
* on rules and move on".
|
||||
*/
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "udyam.ml")
|
||||
@Getter
|
||||
@Setter
|
||||
public class MlProperties {
|
||||
|
||||
/** Off by default: a checkout with no Python running must still boot and score. */
|
||||
private boolean enabled = false;
|
||||
|
||||
private String baseUrl = "http://127.0.0.1:8000";
|
||||
|
||||
/** Per-enterprise call. */
|
||||
private int timeoutMs = 2000;
|
||||
|
||||
/** Batch call over the book — larger, because it is one call for 263 rows. */
|
||||
private int batchTimeoutMs = 30000;
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package com.lincfusion.udyamlinc.risk;
|
||||
|
||||
import com.lincfusion.udyamlinc.enterprise.Enterprise;
|
||||
import com.lincfusion.udyamlinc.enterprise.LedgerEntry;
|
||||
import com.lincfusion.udyamlinc.enterprise.LedgerEntryRepository;
|
||||
import com.lincfusion.udyamlinc.features.FeatureCatalog;
|
||||
import com.lincfusion.udyamlinc.features.FeatureValue;
|
||||
import com.lincfusion.udyamlinc.master.Sector;
|
||||
import com.lincfusion.udyamlinc.ml.MlClient;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.YearMonth;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* The quantile heads, arriving through the seam built for them.
|
||||
*
|
||||
* <p>Selected by {@code udyam.forecast.engine=MODEL}. Answers the half of the
|
||||
* problem statement the rule engine never could: "AI-Driven <b>Cash Flow
|
||||
* Prediction</b>" was, until this existed, a median of past months with a band
|
||||
* widening as sqrt(h) — defensible, explainable, and not a model.
|
||||
*
|
||||
* <h2>What crosses the boundary</h2>
|
||||
*
|
||||
* <p>The feature map, and five trailing statistics computed here from the
|
||||
* ledger. The inference service holds no database credentials and cannot read a
|
||||
* borrower's ledger even if asked — this class already reads it for the
|
||||
* deterministic path, so the query is one that was being made anyway.
|
||||
*
|
||||
* <h2>Fallback</h2>
|
||||
*
|
||||
* <p>Unreachable service, or fewer than three months of ledger, and this returns
|
||||
* an empty list — which {@code RiskEngineService} already treats as "not enough
|
||||
* to forecast from", leaving {@code dscr_p10} null with {@code NO_FORECAST}. That
|
||||
* is the honest answer and is visibly different from a forecast saying the
|
||||
* enterprise is fine.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ModelForecastStrategy implements ForecastStrategy {
|
||||
|
||||
/** Trailing window the trainer used. Changing it here silently degrades the model. */
|
||||
private static final int TRAIL_MONTHS = 6;
|
||||
private static final int MIN_MONTHS = 3;
|
||||
|
||||
private static final List<String> SENT = List.of(
|
||||
FeatureCatalog.FEED_COST_CHANGE,
|
||||
FeatureCatalog.OUTPUT_PRICE_CHANGE,
|
||||
FeatureCatalog.MARGIN_SQUEEZE,
|
||||
FeatureCatalog.PRICE_Z_SCORE,
|
||||
FeatureCatalog.RAINFALL_ANOMALY,
|
||||
FeatureCatalog.HEAT_ANOMALY,
|
||||
FeatureCatalog.CASH_FLOW_CHANGE,
|
||||
FeatureCatalog.EMI_DELAY,
|
||||
FeatureCatalog.INVENTORY_DAYS,
|
||||
FeatureCatalog.FEED_STALENESS_DAYS,
|
||||
FeatureCatalog.DAYS_SINCE_LAST_ENTRY,
|
||||
FeatureCatalog.EMI_TO_INFLOW);
|
||||
|
||||
private final MlClient client;
|
||||
private final LedgerEntryRepository ledgerRepository;
|
||||
private final DeterministicForecastStrategy fallback;
|
||||
|
||||
@Override
|
||||
public String engine() {
|
||||
return "MODEL";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String version() {
|
||||
return "lightgbm-forecast-v1";
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ForecastPoint> forecast(Enterprise enterprise, Sector sector,
|
||||
Map<String, FeatureValue> features, LocalDate asOfDate) {
|
||||
|
||||
Trailing trail = trailing(enterprise.getEnterpriseId(), asOfDate);
|
||||
if (trail.months() < MIN_MONTHS) {
|
||||
// Not the model's decision to make — the same floor the deterministic
|
||||
// strategy uses, so switching engines does not change who gets a forecast.
|
||||
return List.of();
|
||||
}
|
||||
|
||||
Optional<MlClient.MlForecast> answer = client.forecast(request(enterprise, features, trail));
|
||||
if (answer.isEmpty()) {
|
||||
log.debug("Forecasting {} deterministically — model unavailable", enterprise.getEnterpriseId());
|
||||
return fallback.forecast(enterprise, sector, features, asOfDate);
|
||||
}
|
||||
|
||||
BigDecimal obligation = enterprise.getMonthlyInstalment() == null
|
||||
? BigDecimal.ZERO
|
||||
: enterprise.getMonthlyInstalment();
|
||||
|
||||
List<ForecastPoint> points = new ArrayList<>();
|
||||
for (MlClient.MlForecastPoint p : answer.get().points()) {
|
||||
YearMonth month = YearMonth.from(asOfDate).plusMonths(p.horizon());
|
||||
BigDecimal p10 = money(p.p10());
|
||||
points.add(ForecastPoint.builder()
|
||||
.enterpriseId(enterprise.getEnterpriseId())
|
||||
.horizon(p.horizon())
|
||||
.month(month.toString())
|
||||
.p10(p10)
|
||||
.p50(money(p.p50()))
|
||||
.p90(money(p.p90()))
|
||||
.obligation(obligation)
|
||||
.breach(p10.compareTo(obligation) < 0)
|
||||
.engine(engine())
|
||||
.modelVersion(version())
|
||||
.missingInputs(missingCount(features))
|
||||
.build());
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
private Map<String, Object> request(Enterprise enterprise, Map<String, FeatureValue> features, Trailing t) {
|
||||
Map<String, Object> values = new HashMap<>();
|
||||
for (String code : SENT) {
|
||||
FeatureValue row = features.get(code);
|
||||
values.put(code, row == null || row.getValue() == null ? null : row.getValue().doubleValue());
|
||||
}
|
||||
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("enterpriseId", enterprise.getEnterpriseId());
|
||||
body.put("sectorId", enterprise.getSectorId());
|
||||
body.put("districtId", enterprise.getDistrictId());
|
||||
body.put("features", values);
|
||||
body.put("trailMean", t.mean());
|
||||
body.put("trailSd", t.sd());
|
||||
body.put("trailMin", t.min());
|
||||
body.put("trailInflow", t.inflow());
|
||||
body.put("trailMonths", (double) t.months());
|
||||
body.put("emi", enterprise.getMonthlyInstalment() == null
|
||||
? null : enterprise.getMonthlyInstalment().doubleValue());
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Monthly net position over the trailing window, point-in-time.
|
||||
*
|
||||
* <p>Whole months only, and never the month {@code asOfDate} falls in — a
|
||||
* partial month reads as a collapse in income and would teach the model that
|
||||
* every enterprise fails on the first of the month.
|
||||
*/
|
||||
private Trailing trailing(String enterpriseId, LocalDate asOfDate) {
|
||||
YearMonth current = YearMonth.from(asOfDate);
|
||||
YearMonth from = current.minusMonths(TRAIL_MONTHS);
|
||||
|
||||
Map<YearMonth, double[]> byMonth = new TreeMap<>();
|
||||
for (LedgerEntry e : ledgerRepository.findByEnterpriseIdOrderByEntryDateDesc(enterpriseId)) {
|
||||
if (e.getEntryDate() == null || e.getAmount() == null) continue;
|
||||
YearMonth m = YearMonth.from(e.getEntryDate());
|
||||
if (!m.isBefore(current) || m.isBefore(from)) continue;
|
||||
double[] cell = byMonth.computeIfAbsent(m, k -> new double[2]);
|
||||
double amount = e.getAmount().doubleValue();
|
||||
if ("IN".equals(e.getKind())) {
|
||||
cell[0] += amount;
|
||||
cell[1] += amount;
|
||||
} else if ("OUT".equals(e.getKind())) {
|
||||
cell[0] -= amount;
|
||||
}
|
||||
}
|
||||
if (byMonth.isEmpty()) return new Trailing(0, null, null, null, null);
|
||||
|
||||
double[] nets = byMonth.values().stream().mapToDouble(c -> c[0]).toArray();
|
||||
double mean = Arrays.stream(nets).average().orElse(0);
|
||||
double variance = Arrays.stream(nets).map(n -> (n - mean) * (n - mean)).average().orElse(0);
|
||||
double inflow = byMonth.values().stream().mapToDouble(c -> c[1]).average().orElse(0);
|
||||
|
||||
return new Trailing(nets.length, mean, Math.sqrt(variance),
|
||||
Arrays.stream(nets).min().orElse(0), inflow);
|
||||
}
|
||||
|
||||
private int missingCount(Map<String, FeatureValue> features) {
|
||||
int missing = 0;
|
||||
for (String code : SENT) {
|
||||
FeatureValue row = features.get(code);
|
||||
if (row == null || row.getValue() == null) missing++;
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
private BigDecimal money(double value) {
|
||||
return BigDecimal.valueOf(value).setScale(2, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
private record Trailing(int months, Double mean, Double sd, Double min, Double inflow) {}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.lincfusion.udyamlinc.risk;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* One measured number about a model, and everything needed to read it honestly.
|
||||
*
|
||||
* <h2>Why this table exists</h2>
|
||||
*
|
||||
* <p>The Reports page used to render <code>0.87</code> AUC from a constant in
|
||||
* <code>lib/demo/ops.ts</code>. Nothing computed it. A juror asking "how did you
|
||||
* arrive at that?" would have been shown a literal. Every figure the console
|
||||
* publishes about model quality now comes from a row here, written by the
|
||||
* backtest that produced it.
|
||||
*
|
||||
* <h2>Provenance travels with the number</h2>
|
||||
*
|
||||
* <p>{@code syntheticLabelShare} is not decoration. Every DPD label on this book
|
||||
* is generated, so a metric computed against it measures the generator as much
|
||||
* as the borrowers. Storing the share on the same row makes it impossible to
|
||||
* quote the accuracy without also quoting what it was measured on — the console
|
||||
* reads both from one record.
|
||||
*
|
||||
* <h2>Baselines are rows too</h2>
|
||||
*
|
||||
* <p>{@code arm} names what was measured: an algorithm, or a single-feature
|
||||
* baseline. AUC 0.76 is either a real result or a restatement of "a borrower who
|
||||
* is late today is late in three months", and only the comparison says which.
|
||||
* The baselines sit beside the model rather than in a separate table, so a chart
|
||||
* cannot show one without the other being available.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "model_metric", indexes = {
|
||||
@Index(name = "ix_model_metric_run", columnList = "computedAt"),
|
||||
@Index(name = "ix_model_metric_arm", columnList = "arm")
|
||||
})
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class ModelMetric {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
/** The artifact this describes — matches {@code scoring_run.model_version}. */
|
||||
@Column(nullable = false, length = 64)
|
||||
private String modelVersion;
|
||||
|
||||
/** lightgbm | xgboost | repayment_history_only | obligation_ratio_only */
|
||||
@Column(nullable = false, length = 64)
|
||||
private String arm;
|
||||
|
||||
/** True for a single-feature comparison arm rather than a trained model. */
|
||||
@Column(nullable = false)
|
||||
private boolean baseline;
|
||||
|
||||
/** auc_roc | pr_auc | precision_red | recall_red | brier */
|
||||
@Column(nullable = false, length = 40)
|
||||
private String metric;
|
||||
|
||||
@Column(nullable = false, precision = 10, scale = 5)
|
||||
private BigDecimal value;
|
||||
|
||||
// ── what it was measured on ──────────────────────────────
|
||||
|
||||
private Integer datasetRows;
|
||||
private Integer datasetPositives;
|
||||
|
||||
/** 1.0 = every label generated. Read this before reading the value above. */
|
||||
@Column(precision = 5, scale = 4)
|
||||
private BigDecimal syntheticLabelShare;
|
||||
|
||||
@Column(length = 120)
|
||||
private String validation;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDateTime computedAt;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.lincfusion.udyamlinc.risk;
|
||||
|
||||
import com.lincfusion.udyamlinc.common.ApiResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* What the model actually scored, for the Reports page.
|
||||
*
|
||||
* <p>Serves the latest backtest only. Metrics from two runs are not comparable
|
||||
* on one screen — a chart mixing them would show a model beating itself.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/admin/model")
|
||||
@RequiredArgsConstructor
|
||||
public class ModelMetricController {
|
||||
|
||||
private final ModelMetricRepository repository;
|
||||
|
||||
@GetMapping("/metrics")
|
||||
public ApiResponse<Map<String, Object>> metrics() {
|
||||
var latest = repository.findFirstByOrderByComputedAtDesc().orElse(null);
|
||||
if (latest == null) {
|
||||
// No backtest has been run. The console shows that in words rather
|
||||
// than falling back to a number, which is how 0.87 survived so long.
|
||||
return ApiResponse.ok(Map.of("available", false,
|
||||
"message", "No backtest has been run against this database yet."));
|
||||
}
|
||||
|
||||
List<ModelMetric> rows = repository.findByComputedAtOrderByArmAscMetricAsc(latest.getComputedAt());
|
||||
|
||||
Map<String, Map<String, Object>> byArm = new LinkedHashMap<>();
|
||||
for (ModelMetric row : rows) {
|
||||
Map<String, Object> arm = byArm.computeIfAbsent(row.getArm(), k -> new LinkedHashMap<>());
|
||||
arm.put("baseline", row.isBaseline());
|
||||
arm.put(row.getMetric(), row.getValue());
|
||||
}
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("available", true);
|
||||
body.put("modelVersion", latest.getModelVersion());
|
||||
body.put("computedAt", latest.getComputedAt());
|
||||
body.put("validation", latest.getValidation());
|
||||
body.put("datasetRows", latest.getDatasetRows());
|
||||
body.put("datasetPositives", latest.getDatasetPositives());
|
||||
body.put("syntheticLabelShare", latest.getSyntheticLabelShare());
|
||||
body.put("arms", byArm);
|
||||
return ApiResponse.ok(body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.lincfusion.udyamlinc.risk;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface ModelMetricRepository extends JpaRepository<ModelMetric, Long> {
|
||||
|
||||
/** The most recent backtest. Metrics from two different runs must not be mixed. */
|
||||
Optional<ModelMetric> findFirstByOrderByComputedAtDesc();
|
||||
|
||||
List<ModelMetric> findByComputedAtOrderByArmAscMetricAsc(LocalDateTime computedAt);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package com.lincfusion.udyamlinc.risk;
|
||||
|
||||
import com.lincfusion.udyamlinc.enterprise.Enterprise;
|
||||
import com.lincfusion.udyamlinc.features.FeatureCatalog;
|
||||
import com.lincfusion.udyamlinc.features.FeatureValue;
|
||||
import com.lincfusion.udyamlinc.master.Sector;
|
||||
import com.lincfusion.udyamlinc.ml.MlClient;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* The gradient-boosted engine, arriving through the seam that was built for it.
|
||||
*
|
||||
* <p>Registered as another {@link ScoringStrategy} bean; {@code RiskEngineService}
|
||||
* resolves it by name from {@code udyam.risk.engine=MODEL}. Nothing downstream
|
||||
* changes — the score, the sub-scores, the reason codes and the alerts are the
|
||||
* same records the rule engine produces, because both write {@link ScoreOutcome}.
|
||||
*
|
||||
* <h2>Fallback is the point, not a nicety</h2>
|
||||
*
|
||||
* <p>If the inference service is unreachable, slow or wrong, this delegates to
|
||||
* {@link RuleBasedScoringStrategy} for that enterprise and marks the outcome so a
|
||||
* reader can tell which engine actually produced it. A nightly run must complete;
|
||||
* a demo must survive the Python process dying mid-presentation. The alternative
|
||||
* — a run that aborts because a sidecar is down — trades a slightly worse score
|
||||
* for no score at all.
|
||||
*
|
||||
* <h2>What crosses the boundary</h2>
|
||||
*
|
||||
* <p>The feature map, the sector and district ids, and three loan-book figures.
|
||||
* No name, no phone, no account reference — the schema does not hold them, and
|
||||
* this service must never become the reason someone adds them.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ModelScoringStrategy implements ScoringStrategy {
|
||||
|
||||
/** The features the model was trained on. Order is the service's business, not ours. */
|
||||
private static final List<String> SENT = List.of(
|
||||
FeatureCatalog.FEED_COST_CHANGE,
|
||||
FeatureCatalog.OUTPUT_PRICE_CHANGE,
|
||||
FeatureCatalog.MARGIN_SQUEEZE,
|
||||
FeatureCatalog.PRICE_Z_SCORE,
|
||||
FeatureCatalog.RAINFALL_ANOMALY,
|
||||
FeatureCatalog.HEAT_ANOMALY,
|
||||
FeatureCatalog.CASH_FLOW_CHANGE,
|
||||
FeatureCatalog.EMI_DELAY,
|
||||
FeatureCatalog.INVENTORY_DAYS,
|
||||
FeatureCatalog.UPI_INFLOW_CHANGE,
|
||||
FeatureCatalog.FEED_STALENESS_DAYS,
|
||||
FeatureCatalog.DAYS_SINCE_LAST_ENTRY,
|
||||
FeatureCatalog.EMI_TO_INFLOW);
|
||||
|
||||
private final MlClient client;
|
||||
private final RuleBasedScoringStrategy fallback;
|
||||
|
||||
@Override
|
||||
public String engine() {
|
||||
return "MODEL";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String modelVersion() {
|
||||
return client.modelInfo()
|
||||
.map(info -> String.valueOf(info.getOrDefault("modelVersion", "model-unknown")))
|
||||
.orElse("model-unavailable");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ScoreOutcome score(Enterprise enterprise, Sector sector, Map<String, FeatureValue> features) {
|
||||
return client.score(request(enterprise, features))
|
||||
.map(this::toOutcome)
|
||||
.orElseGet(() -> {
|
||||
log.debug("Scoring {} on rules — model unavailable", enterprise.getEnterpriseId());
|
||||
// Attributed to RULE_FALLBACK, not MODEL. The run is a model run;
|
||||
// this score is not, and the difference has to survive to the table.
|
||||
return fallback.score(enterprise, sector, features).attributedTo("RULE_FALLBACK");
|
||||
});
|
||||
}
|
||||
|
||||
private Map<String, Object> request(Enterprise enterprise, Map<String, FeatureValue> features) {
|
||||
Map<String, Object> values = new HashMap<>();
|
||||
for (String code : SENT) {
|
||||
FeatureValue row = features.get(code);
|
||||
// A null goes across as a null. The model was trained with an explicit
|
||||
// missing-indicator per column, so "we did not have this" is a state it
|
||||
// has seen — dropping the key would silently make it a zero.
|
||||
values.put(code, row == null || row.getValue() == null ? null : row.getValue().doubleValue());
|
||||
}
|
||||
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("enterpriseId", enterprise.getEnterpriseId());
|
||||
body.put("sectorId", enterprise.getSectorId());
|
||||
body.put("districtId", enterprise.getDistrictId());
|
||||
body.put("features", values);
|
||||
// DPD at the as-of date is already in the feature store as emi_delay; reading
|
||||
// it from there keeps the request to one source of truth.
|
||||
body.put("dpdAtT", doubleOf(features.get(FeatureCatalog.EMI_DELAY)));
|
||||
body.put("outstandingAtT", asDouble(enterprise.getLoanOutstanding()));
|
||||
body.put("emiAtT", asDouble(enterprise.getMonthlyInstalment()));
|
||||
return body;
|
||||
}
|
||||
|
||||
private ScoreOutcome toOutcome(MlClient.MlScore ml) {
|
||||
List<ScoreOutcome.Driver> drivers = new ArrayList<>();
|
||||
for (MlClient.MlDriver d : ml.drivers()) {
|
||||
drivers.add(new ScoreOutcome.Driver(d.family(), d.weight(), d.featureCode(), d.value(), d.onPrior()));
|
||||
}
|
||||
return new ScoreOutcome(
|
||||
ml.score(), ml.band(), ml.confidence(), ml.subScores(), drivers, ml.familiesOnPrior());
|
||||
}
|
||||
|
||||
private Double doubleOf(FeatureValue row) {
|
||||
return row == null || row.getValue() == null ? null : row.getValue().doubleValue();
|
||||
}
|
||||
|
||||
private Double asDouble(BigDecimal value) {
|
||||
return value == null ? null : value.doubleValue();
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,7 @@ public class RiskEngineService {
|
||||
.enterpriseId(enterprise.getEnterpriseId())
|
||||
.score(scored.score())
|
||||
.band(band)
|
||||
.engine(strategy.engine())
|
||||
.engine(scored.engine() != null ? scored.engine() : strategy.engine())
|
||||
.confidence(scored.confidence())
|
||||
.liquidity(scored.subScores().get(RuleBasedScoringStrategy.LIQUIDITY))
|
||||
.behavioural(scored.subScores().get(RuleBasedScoringStrategy.BEHAVIOURAL))
|
||||
|
||||
@@ -17,7 +17,31 @@ public record ScoreOutcome(
|
||||
Map<String, Integer> subScores,
|
||||
List<Driver> drivers,
|
||||
/** Families that had no usable feature and fell back to the sector prior. */
|
||||
int familiesOnPrior) {
|
||||
int familiesOnPrior,
|
||||
|
||||
/**
|
||||
* What actually produced this score, when that differs from the engine the
|
||||
* run was configured with.
|
||||
*
|
||||
* <p>Null means "the configured engine", which is the normal case. It is
|
||||
* set when {@link ModelScoringStrategy} cannot reach the inference service
|
||||
* and answers from the rule engine instead: the run is still a MODEL run,
|
||||
* but <em>this</em> score is not, and stamping the whole run MODEL would
|
||||
* be the platform telling itself a provenance lie of exactly the kind the
|
||||
* simulated/real distinction exists to prevent.
|
||||
*/
|
||||
String engine) {
|
||||
|
||||
/** Engine unset — the score came from the strategy the run is configured with. */
|
||||
public ScoreOutcome(int score, String band, int confidence, Map<String, Integer> subScores,
|
||||
List<Driver> drivers, int familiesOnPrior) {
|
||||
this(score, band, confidence, subScores, drivers, familiesOnPrior, null);
|
||||
}
|
||||
|
||||
/** The same outcome, attributed to a different engine. */
|
||||
public ScoreOutcome attributedTo(String actualEngine) {
|
||||
return new ScoreOutcome(score, band, confidence, subScores, drivers, familiesOnPrior, actualEngine);
|
||||
}
|
||||
|
||||
/**
|
||||
* One family's contribution to the composite. In the rule engine this is the
|
||||
|
||||
@@ -195,3 +195,12 @@ udyam.forecast.trust-seeded=${UDYAM_TRUST_SEEDED_FORECASTS:false}
|
||||
# to the enterprise — at the 0.75 ceiling P10 is a quarter of P50 for everyone,
|
||||
# so a worst-of-six rule separates nobody from anybody.
|
||||
udyam.forecast.tripwire-horizon=3
|
||||
|
||||
# ── ML inference service ───────────────────────────────────────────
|
||||
# Off by default: a checkout with no Python running must still boot and score.
|
||||
# Turn both on together — udyam.risk.engine=MODEL without udyam.ml.enabled=true
|
||||
# would resolve the model strategy and then fall back on every enterprise.
|
||||
udyam.ml.enabled=${UDYAM_ML_ENABLED:false}
|
||||
udyam.ml.base-url=${UDYAM_ML_BASE_URL:http://127.0.0.1:8000}
|
||||
udyam.ml.timeout-ms=2000
|
||||
udyam.ml.batch-timeout-ms=30000
|
||||
|
||||
Reference in New Issue
Block a user