Skip to content

Commit 99b5b46

Browse files
authored
feat: getBestBanditAction (#161)
* move bandit event logging up * extract action sorting * bump version for release
1 parent 9189f29 commit 99b5b46

File tree

5 files changed

+206
-15
lines changed

5 files changed

+206
-15
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@eppo/js-client-sdk-common",
3-
"version": "4.5.4",
3+
"version": "4.6.0",
44
"description": "Eppo SDK for client-side JavaScript applications (base for both web and react native)",
55
"main": "dist/index.js",
66
"files": [

src/bandit-evaluator.spec.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,4 +425,94 @@ describe('BanditEvaluator', () => {
425425
expect(resultB.optimalityGap).toBeCloseTo(0.3);
426426
});
427427
});
428+
429+
describe('evaluateBestBandit', () => {
430+
it('evaluates the bandit with action contexts', () => {
431+
const subjectAttributes: ContextAttributes = {
432+
numericAttributes: { age: 25 },
433+
categoricalAttributes: { location: 'US' },
434+
};
435+
const subjectAttributesB: ContextAttributes = {
436+
numericAttributes: { age: 25 },
437+
categoricalAttributes: {},
438+
};
439+
const actions: Record<string, ContextAttributes> = {
440+
action1: { numericAttributes: { price: 10 }, categoricalAttributes: { category: 'A' } },
441+
action2: { numericAttributes: { price: 20 }, categoricalAttributes: { category: 'B' } },
442+
};
443+
const banditModel: BanditModelData = {
444+
gamma: 0.1,
445+
defaultActionScore: 0.0,
446+
actionProbabilityFloor: 0.1,
447+
coefficients: {
448+
action1: {
449+
actionKey: 'action1',
450+
intercept: 0.5,
451+
subjectNumericCoefficients: [
452+
{ attributeKey: 'age', coefficient: 0.1, missingValueCoefficient: 0.0 },
453+
],
454+
subjectCategoricalCoefficients: [
455+
{
456+
attributeKey: 'location',
457+
missingValueCoefficient: 0.0,
458+
valueCoefficients: { US: 0.2 },
459+
},
460+
],
461+
actionNumericCoefficients: [
462+
{ attributeKey: 'price', coefficient: 0.05, missingValueCoefficient: 0.0 },
463+
],
464+
actionCategoricalCoefficients: [
465+
{
466+
attributeKey: 'category',
467+
missingValueCoefficient: 0.0,
468+
valueCoefficients: { A: 0.3 },
469+
},
470+
],
471+
},
472+
action2: {
473+
actionKey: 'action2',
474+
intercept: 0.3,
475+
subjectNumericCoefficients: [
476+
{ attributeKey: 'age', coefficient: 0.1, missingValueCoefficient: 0.0 },
477+
],
478+
subjectCategoricalCoefficients: [
479+
{
480+
attributeKey: 'location',
481+
missingValueCoefficient: -3.0,
482+
valueCoefficients: { US: 0.2 },
483+
},
484+
],
485+
actionNumericCoefficients: [
486+
{ attributeKey: 'price', coefficient: 0.05, missingValueCoefficient: 0.0 },
487+
],
488+
actionCategoricalCoefficients: [
489+
{
490+
attributeKey: 'category',
491+
missingValueCoefficient: 0.0,
492+
valueCoefficients: { B: 0.3 },
493+
},
494+
],
495+
},
496+
},
497+
};
498+
499+
// Subject A gets assigned action 2
500+
const resultA = banditEvaluator.evaluateBestBanditAction(
501+
subjectAttributes,
502+
actions,
503+
banditModel,
504+
);
505+
506+
expect(resultA).toEqual('action2');
507+
508+
// Subject B gets assigned action 1 because of the missing location penalty
509+
const resultB = banditEvaluator.evaluateBestBanditAction(
510+
subjectAttributesB,
511+
actions,
512+
banditModel,
513+
);
514+
515+
expect(resultB).toEqual('action1');
516+
});
517+
});
428518
});

src/bandit-evaluator.ts

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,21 @@ export class BanditEvaluator {
2323
private readonly assignmentShards = BANDIT_ASSIGNMENT_SHARDS; // We just hard code this for now
2424
private readonly sharder: Sharder = new MD5Sharder();
2525

26+
public evaluateBestBanditAction(
27+
subjectAttributes: ContextAttributes,
28+
actions: Record<string, ContextAttributes>,
29+
banditModel: BanditModelData,
30+
): string | null {
31+
const actionScores: Record<string, number> = this.scoreActions(
32+
subjectAttributes,
33+
actions,
34+
banditModel,
35+
);
36+
37+
const { topAction } = this.getTopScore(actionScores);
38+
return topAction;
39+
}
40+
2641
public evaluateBandit(
2742
flagKey: string,
2843
subjectKey: string,
@@ -140,20 +155,7 @@ export class BanditEvaluator {
140155
return actionWeights;
141156
}
142157

143-
// First find the action with the highest score
144-
let currTopScore: number | null = null;
145-
let currTopAction: string | null = null;
146-
actionScoreEntries.forEach(([actionKey, actionScore]) => {
147-
if (
148-
currTopScore === null ||
149-
currTopAction === null ||
150-
actionScore > currTopScore ||
151-
(actionScore === currTopScore && actionKey < currTopAction)
152-
) {
153-
currTopScore = actionScore;
154-
currTopAction = actionKey;
155-
}
156-
});
158+
const { topScore: currTopScore, topAction: currTopAction } = this.getTopScore(actionScores);
157159

158160
if (currTopScore === null || currTopAction === null) {
159161
// Appease typescript with this check and extra variables
@@ -228,4 +230,27 @@ export class BanditEvaluator {
228230
}
229231
return assignedAction;
230232
}
233+
234+
private getTopScore(actionScores: Record<string, number>): {
235+
topScore: number | null;
236+
topAction: string | null;
237+
} {
238+
const actionScoreEntries = Object.entries(actionScores);
239+
// Find the action with the highest score, tie-breaking by name, selecting the alpha-numerically smaller key.
240+
let topScore: number | null = null;
241+
let topAction: string | null = null;
242+
actionScoreEntries.forEach(([actionKey, actionScore]) => {
243+
if (
244+
topScore === null ||
245+
topAction === null ||
246+
actionScore > topScore ||
247+
(actionScore === topScore && actionKey < topAction)
248+
) {
249+
topScore = actionScore;
250+
topAction = actionKey;
251+
}
252+
});
253+
254+
return { topScore, topAction };
255+
}
231256
}

src/client/eppo-client-with-bandits.spec.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,41 @@ describe('EppoClient Bandits E2E test', () => {
452452
});
453453
});
454454

455+
describe('Best bandit action', () => {
456+
it('Selects the highest scoring action', async () => {
457+
const actions: Record<string, ContextAttributes> = {
458+
nike: {
459+
numericAttributes: { brand_affinity: 1.0 },
460+
categoricalAttributes: {},
461+
},
462+
adidas: {
463+
numericAttributes: { brand_affinity: 1.0 },
464+
categoricalAttributes: {},
465+
},
466+
reebok: {
467+
numericAttributes: { brand_affinity: 2.0 },
468+
categoricalAttributes: {},
469+
},
470+
};
471+
472+
const subjectAttributesWithAreaCode: Attributes = {
473+
age: 25,
474+
mistake: 'oops',
475+
country: 'USA',
476+
gender_identity: 'female',
477+
area_code: '303', // categorical area code
478+
};
479+
480+
const banditAssignment = client.getBestAction(
481+
flagKey,
482+
subjectAttributesWithAreaCode,
483+
actions,
484+
'default',
485+
);
486+
expect(banditAssignment).toBe('adidas');
487+
});
488+
});
489+
455490
describe('Assignment logging deduplication', () => {
456491
let mockEvaluateFlag: jest.SpyInstance;
457492
let mockEvaluateBandit: jest.SpyInstance;

src/client/eppo-client.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,47 @@ export default class EppoClient {
501501
return { variation, action };
502502
}
503503

504+
/**
505+
* Evaluates the supplied actions using the first bandit associated with `flagKey` and returns the best ranked action.
506+
*
507+
* This method should be considered **preview** and is subject to change as requirements mature.
508+
*
509+
* NOTE: This method does not do any logging or assignment computation and so calling this method will have
510+
* NO IMPACT on bandit and experiment training.
511+
*
512+
* Only use this method under certain circumstances (i.e. where the impact of the choice of bandit cannot be measured,
513+
* but you want to put the "best foot forward", for example, when being web-crawled).
514+
*
515+
*/
516+
getBestAction(
517+
flagKey: string,
518+
subjectAttributes: BanditSubjectAttributes,
519+
actions: BanditActions,
520+
defaultAction: string,
521+
): string {
522+
let result: string | null = null;
523+
524+
const flagBanditVariations = this.banditVariationConfigurationStore?.get(flagKey);
525+
const banditKey = flagBanditVariations?.at(0)?.key;
526+
527+
if (banditKey) {
528+
const banditParameters = this.banditModelConfigurationStore?.get(banditKey);
529+
if (banditParameters) {
530+
const contextualSubjectAttributes =
531+
this.ensureContextualSubjectAttributes(subjectAttributes);
532+
const actionsWithContextualAttributes = this.ensureActionsWithContextualAttributes(actions);
533+
534+
result = this.banditEvaluator.evaluateBestBanditAction(
535+
contextualSubjectAttributes,
536+
actionsWithContextualAttributes,
537+
banditParameters.modelData,
538+
);
539+
}
540+
}
541+
542+
return result ?? defaultAction;
543+
}
544+
504545
getBanditActionDetails(
505546
flagKey: string,
506547
subjectKey: string,

0 commit comments

Comments
 (0)