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
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.bablsoft.accessflow.ai.internal;

/**
* Thrown when a {@link TrainedApprovalModel} cannot be serialized to or deserialized from JSON —
* for example when a stored model row is corrupt or the schema version is unrecognized.
* Callers (the retraining job, the serving path) should catch this and record a {@code failed}
* sentinel rather than propagating an anonymous {@link RuntimeException}.
*/
public class ApprovalModelSerializationException extends RuntimeException {

public ApprovalModelSerializationException(String message, Throwable cause) {
super(message, cause);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package com.bablsoft.accessflow.ai.internal;

import org.springframework.stereotype.Component;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Component
public class LogisticRegressionTrainer {

public TrainedApprovalModel train(double[][] features, boolean[] labels, List<String> featureNames, double lambda, int maxIterations) {
int n = features.length;
if (n == 0) {
throw new IllegalArgumentException("No training samples");
}
int m = featureNames.size();
Comment thread
ashish-babu-03 marked this conversation as resolved.
if (labels.length != n) {
throw new IllegalArgumentException(
"labels.length (" + labels.length + ") must equal features.length (" + n + ")");
}
for (int i = 0; i < n; i++) {
if (features[i].length != m) {
throw new IllegalArgumentException(
"features[" + i + "].length (" + features[i].length
+ ") must equal featureNames.size() (" + m + ")");
}
}

double[] means = new double[m];
double[] stddevs = new double[m];

// Calculate means
for (int j = 0; j < m; j++) {
double sum = 0;
for (int i = 0; i < n; i++) {
sum += features[i][j];
}
means[j] = sum / n;
}

// Calculate stddevs (sample stddev)
for (int j = 0; j < m; j++) {
double sumSq = 0;
for (int i = 0; i < n; i++) {
double diff = features[i][j] - means[j];
sumSq += diff * diff;
}
if (n > 1) {
stddevs[j] = Math.sqrt(sumSq / (n - 1));
} else {
stddevs[j] = 0.0;
}
if (stddevs[j] < 1e-9) {
stddevs[j] = 1.0;
}
}

// Standardize features
double[][] scaled = new double[n][m];
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
scaled[i][j] = (features[i][j] - means[j]) / stddevs[j];
}
}

// Gradient descent
double[] w = new double[m];
double b = 0;
double lr = 0.1;
double prevLoss = Double.MAX_VALUE;

for (int iter = 0; iter < maxIterations; iter++) {
double[] dw = new double[m];
double db = 0;
double loss = 0;

for (int i = 0; i < n; i++) {
double logit = b;
for (int j = 0; j < m; j++) {
logit += w[j] * scaled[i][j];
}

double p = 1.0 / (1.0 + Math.exp(-logit));
double y = labels[i] ? 1.0 : 0.0;

// Avoid log(0)
double pSafe = Math.max(1e-15, Math.min(1.0 - 1e-15, p));
loss -= (y * Math.log(pSafe) + (1.0 - y) * Math.log(1.0 - pSafe));

double dz = p - y;
db += dz;
for (int j = 0; j < m; j++) {
dw[j] += dz * scaled[i][j];
}
}

loss /= n;
for (int j = 0; j < m; j++) {
loss += (lambda / 2.0) * w[j] * w[j];
dw[j] = (dw[j] / n) + (lambda * w[j]);
}
db /= n;

if (prevLoss - loss < 1e-7 && prevLoss - loss >= 0) {
break;
}
prevLoss = loss;

for (int j = 0; j < m; j++) {
w[j] -= lr * dw[j];
}
b -= lr * db;
}

Map<String, Double> weightsMap = new HashMap<>();
Map<String, Double> meansMap = new HashMap<>();
Map<String, Double> stddevsMap = new HashMap<>();

for (int j = 0; j < m; j++) {
String name = featureNames.get(j);
weightsMap.put(name, w[j]);
meansMap.put(name, means[j]);
stddevsMap.put(name, stddevs[j]);
}

return new TrainedApprovalModel("v1", featureNames, b, weightsMap, meansMap, stddevsMap);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package com.bablsoft.accessflow.ai.internal;

import org.springframework.stereotype.Component;

import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;

@Component
public class ModelEvaluator {

public double calculateAuc(double[] probabilities, boolean[] labels) {
int n = probabilities.length;
if (labels.length != n) {
throw new IllegalArgumentException(
"labels.length (" + labels.length + ") must equal probabilities.length (" + n + ")");
}
if (n == 0) {
return 0.5;
}
Comment thread
ashish-babu-03 marked this conversation as resolved.

List<Prediction> preds = new ArrayList<>(n);
long nPos = 0;
long nNeg = 0;

for (int i = 0; i < n; i++) {
preds.add(new Prediction(probabilities[i], labels[i]));
if (labels[i]) {
nPos++;
} else {
nNeg++;
}
}

if (nPos == 0 || nNeg == 0) {
return 0.5;
}

preds.sort(Comparator.comparingDouble(Prediction::prob));

double sumPosRanks = 0;
int i = 0;
while (i < n) {
int j = i;
while (j < n && Math.abs(preds.get(j).prob() - preds.get(i).prob()) < 1e-9) {
j++;
}
double avgRank = (i + 1 + j) / 2.0;
for (int k = i; k < j; k++) {
if (preds.get(k).label()) {
sumPosRanks += avgRank;
}
}
i = j;
}

return (sumPosRanks - (nPos * (nPos + 1.0)) / 2.0) / (double) (nPos * nNeg);
}

public double calculateAccuracy(double[] probabilities, boolean[] labels, double threshold) {
if (labels.length != probabilities.length) {
throw new IllegalArgumentException(
"labels.length (" + labels.length + ") must equal probabilities.length ("
+ probabilities.length + ")");
}
if (probabilities.length == 0) {
return 1.0;
}
int correct = 0;
for (int i = 0; i < probabilities.length; i++) {
boolean pred = probabilities[i] >= threshold;
if (pred == labels[i]) {
correct++;
}
}
return (double) correct / probabilities.length;
}

private record Prediction(double prob, boolean label) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package com.bablsoft.accessflow.ai.internal;

import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;

import java.util.HashSet;
import java.util.List;
import java.util.Map;

public record TrainedApprovalModel(
String featureSchemaVersion,
List<String> featureNames,
double intercept,
Map<String, Double> weights,
Map<String, Double> means,
Map<String, Double> stddevs
) {
public TrainedApprovalModel {
featureNames = List.copyOf(featureNames);
weights = Map.copyOf(weights);
means = Map.copyOf(means);
stddevs = Map.copyOf(stddevs);

// Reject duplicate feature names — a duplicate collapses in the maps and
// would cause predict() to silently double-count the surviving weight.
var seen = new HashSet<String>();
for (var name : featureNames) {
if (!seen.add(name)) {
throw new IllegalArgumentException(
"Duplicate feature name: '" + name + "'");
}
}

// Validate that every declared feature has a coefficient, mean, and stddev.
// A missing entry would cause predict() to silently substitute 0/1 defaults
// and return a plausible-looking but wrong probability.
for (var name : featureNames) {
if (!weights.containsKey(name)) {
throw new IllegalArgumentException("Missing weight for feature: '" + name + "'");
}
if (!means.containsKey(name)) {
throw new IllegalArgumentException("Missing mean for feature: '" + name + "'");
}
if (!stddevs.containsKey(name)) {
throw new IllegalArgumentException("Missing stddev for feature: '" + name + "'");
}
}
}

public double predict(double[] rawFeatures) {
if (rawFeatures.length != featureNames.size()) {
throw new IllegalArgumentException(
"Feature length mismatch: expected " + featureNames.size()
+ ", got " + rawFeatures.length);
}
double logit = intercept;
for (int i = 0; i < rawFeatures.length; i++) {
var fname = featureNames.get(i);
double val = rawFeatures[i];
double mean = means.get(fname);
double std = stddevs.get(fname);
double scaled = (val - mean) / std;
double w = weights.get(fname);
logit += w * scaled;
}
return 1.0 / (1.0 + Math.exp(-logit));
}

public String toJson(ObjectMapper mapper) {
try {
return mapper.writeValueAsString(this);
} catch (JacksonException e) {
throw new ApprovalModelSerializationException(
"Failed to serialize approval model", e);
}
}

public static TrainedApprovalModel fromJson(String json, ObjectMapper mapper) {
try {
return mapper.readValue(json, TrainedApprovalModel.class);
} catch (JacksonException e) {
throw new ApprovalModelSerializationException(
"Failed to deserialize approval model", e);
}
}
Comment thread
ashish-babu-03 marked this conversation as resolved.
}
Loading