import { Campaign, ChoiceRequest, CampaignGroup } from "../../models"; import { StepResult } from "../stepResult"; import SpeedService from "../../services/speed.service"; import { FeatureConfigService } from "../../services/FeatureConfig.service"; import AudienceHashMap from "../../services/scoringSystem/audienceHashMap.service"; import CallToActionHashMap from "../../services/scoringSystem/callToActionHashMap.service"; import MetricService, { BucketSet } from "../../services/metric.service"; import { CampaignCreative, CompositeScoreStrategy, SortStrategy, Weights, WeightsMap, } from "./ordering.model"; import { ContextService } from "../../services/targeting/ContextService"; import _ from "lodash"; import logger from "../../utils/logger"; import constants from "../../utils/constants"; import { AbstractStep } from "../abstractStep"; import { BucketMapping } from "../../services/scoringSystem/abstractScoreHashMap.service"; import { bisectRight, getRandomItem } from "../../utils/helper"; import { CampaignData, CampaignModel } from "../../models/CampaignModel"; import { getFormatFromId } from "../../utils/format"; const SCORE_UNIFORMIZATION_COUNTER = "score_uniformization"; const ORDERING_STAGE_HISTOGRAM = "ordering_stage_duration_seconds"; const SPLIT_CAMPAIGN_CREATIVE = "split_campaign_creative_total"; const SORT_CAMPAIGNS_COUNTER = "sort_campaigns_total"; const GET_COMPOSITE_SCORE_COUNTER = "get_composite_score_total"; const CREATIVE_COUNT_HISTOGRAM = "creative_count_total"; const SCORE_PRESENCE_COUNTER = "score_presence_total"; const SCORE_WEIGHT_COUNTER = "score_weight_total"; class OrderingStep extends AbstractStep { private metricService: MetricService; constructor( private readonly campaignModel: CampaignModel, private readonly contextService: ContextService, private readonly speedService: SpeedService, private readonly audienceHashMap: AudienceHashMap, private readonly callToActionHashMap: CallToActionHashMap ) { super("ordering"); this.reportCreativeFiltering = true; this.metricBucket = BucketSet.zeroTo25ms; this.autoPassCampaignGroup = CampaignGroup.Marketplace | CampaignGroup.Fallback; this.metricService = MetricService.getInstance(); this.featureConfigService = FeatureConfigService.getInstance(); this.metricService.addCounter( SCORE_UNIFORMIZATION_COUNTER, "Number of calls to score uniformization function", ["score_type", "num_buckets"] ); this.metricService.addHistogram( ORDERING_STAGE_HISTOGRAM, "Timings for various parts of the ordering step", ["stage"], BucketSet.zeroTo10ms ); this.metricService.addCounter( SPLIT_CAMPAIGN_CREATIVE, "Number of splits into campaign creative pair", ["type"] ); this.metricService.addCounter( SCORE_PRESENCE_COUNTER, "Counts whether each hashmap score was present for each campaign or not", ["score_name", "present"] ); this.metricService.addCounter(GET_COMPOSITE_SCORE_COUNTER, "Number of get composite score", [ "strategy", "popIndex", ]); this.metricService.addCounter(SORT_CAMPAIGNS_COUNTER, "Number of campaign sorting", [ "strategy", ]); this.metricService.addCounter(SCORE_WEIGHT_COUNTER, "Number of used weight strategy", [ "strategy", ]); this.metricService.addHistogram( CREATIVE_COUNT_HISTOGRAM, "number of creative per campaign", ["format"], [1, 2, 3, 4, 5, 7, 9, 10, 11, 13, 15, 20] ); } async run(request: ChoiceRequest): Promise { const result: StepResult = new StepResult(); // add speed data await this.timer(request, "speed_threshold_metadata", () => this.speedService.addThresholdMetadata(request) ); // split campaigns into campaign-creative pairs // for now, keep only one campaign-crea pair by campaign (random) const campaignCreatives: CampaignCreative[] = this.splitToCampaignCreativePairs(request); // get needed campaign features to add context scores, cta scores and get weights await this.timer(request, "campaign_features", () => { campaignCreatives.forEach((cc) => { const campaignFeatures = this.getCampaignFeatures(request, cc); Object.assign(cc.metadataTmp, campaignFeatures); }); }); await this.timer(request, "context", () => this.contextService.addScores(request, campaignCreatives) ); // get all audience scores and add them to metadata await this.timer(request, "audience", () => this.audienceHashMap.addScores( request, campaignCreatives, request.app?.bundle_id, request.site?.page?.toLowerCase().trim() ) ); // get all cta scores and add them to metadata await this.timer(request, "cta", () => this.callToActionHashMap.addScores(campaignCreatives, { ssp: request.context?.ssp, assetId: request.app?.bundle_id?.toLowerCase(), assetUnit: request.isWeb ? request.site?.name?.toLowerCase() : request.app?.ad_unit_id, }) ); this.countScorePresence(request, campaignCreatives); // calculate the composite scores await this.timer(request, "normalize", () => this.uniformizeScores(request, campaignCreatives)); // sort campaigns await this.timer(request, "sort", () => this.sort(request, campaignCreatives)); this.log(campaignCreatives); // convert back to campaigns request.campaigns = campaignCreatives.map((cc) => { cc.campaign.creatives = [cc.creativeId]; Object.assign(cc.campaign.metadata, cc.metadata); Object.assign(cc.campaign.metadataTmp, cc.metadataTmp); return cc.campaign; }); result.passCampaigns(...request.campaigns); return result; } /** * * @param request Choices request * @param campaign Campaign * @returns Expected income */ private getIncome(request: ChoiceRequest, campaignCreative: CampaignCreative): number { const campaignData = request.campaignData[campaignCreative.campaign.id]; if (!campaignData.clicks_sold || !campaignData.budget || !campaignCreative.metadata.acc_rate) { return 0; } return (campaignData.budget / campaignData.clicks_sold) * campaignCreative.metadata.acc_rate; } /** * Splits campaigns into campaign-creative pairs. * Keep only one campaign-creative pair for each campaign, choose it randomly. * @param request * @private */ private splitToCampaignCreativePairs(request: ChoiceRequest): CampaignCreative[] { return request.campaigns.reduce((acc: CampaignCreative[], campaign: Campaign) => { const creatives = request.campaignData[campaign.id].creatives; if (!creatives || creatives.length === 0) { this.metricService.incCounterWithReq(request, SPLIT_CAMPAIGN_CREATIVE, 1, { type: "no_crea_in_data", }); logger.error(new Error(`[multi crea] No creative found in campaign ${campaign.id}`)); } else if (!campaign.creatives || campaign.creatives.length === 0) { // if no creative ID is provided in the request, then take the first creative of the campaign (should have only 1) // external request should go through this block this.metricService.incCounterWithReq(request, SPLIT_CAMPAIGN_CREATIVE, 1, { type: "no_crea_candidate", }); logger.debug( () => `[multi crea] Splitting campaign ${campaign.id}: no creative candidate -> using 1st creative in campaign data` ); acc.push(this.initCreativeObject(campaign, creatives[0].id)); } else { this.metricService.observeWithReq(request, CREATIVE_COUNT_HISTOGRAM, creatives.length, { format: request.app?.ad_unit_type ?? "", }); // For now, only pick one creative randomly. const creativeId = getRandomItem(campaign.creatives); // check creative exists for this campaign and create campaign-creative pair if (!creatives.some((creative) => creative.id === creativeId)) { this.metricService.incCounterWithReq(request, SPLIT_CAMPAIGN_CREATIVE, 1, { type: "wrong_crea_candidate", }); return acc; } this.metricService.incCounterWithReq(request, SPLIT_CAMPAIGN_CREATIVE, 1, { type: "crea_candidate", }); logger.debug( () => `[multi crea] Splitting campaign ${campaign.id}: creatives candidate: ${campaign.creatives} -> selected: ${creativeId}` ); campaign.metadata.creative_id = creativeId; acc.push(this.initCreativeObject(campaign, creativeId)); } return acc; }, []); } initCreativeObject(campaign: Campaign, creativeId: number): CampaignCreative { return { campaign: campaign, creativeId, metadata: Object.assign({}, campaign.metadata), metadataTmp: Object.assign({}, campaign.metadataTmp), }; } public uniformizeScores(request: ChoiceRequest, campaignCreatives: CampaignCreative[]) { // Retrieve method used to compute composite score const [weightsMap, popIndex] = this.buildWeightsMap(request); const compositeScoreStrategy = this.featureConfigService .getStringValue(request, "ms-choices.composite-score-strategy", CompositeScoreStrategy.NORMAL) .toUpperCase() as CompositeScoreStrategy; logger.debug(() => `composite score strategy: ${compositeScoreStrategy}`); request.metadata.composite_score_strategy = compositeScoreStrategy; this.metricService.incCounterWithReq( request, GET_COMPOSITE_SCORE_COUNTER, campaignCreatives.length, { strategy: compositeScoreStrategy, popIndex: popIndex, } ); campaignCreatives.forEach((campaignCreative) => { // Fetch needed values const [data, dataTmp] = [campaignCreative.metadata, campaignCreative.metadataTmp]; const [weights, strategy] = this.getWeights( campaignCreative, request.campaignData[campaignCreative.campaign.id], weightsMap ); this.metricService.incCounterWithReq(request, SCORE_WEIGHT_COUNTER, 1, { strategy: strategy, }); // Scores uniformization data.uniformized_context_score = this.uniformize( data.context_score, dataTmp.accMetadata?.bucketsMap, request, "context" ); data.uniformized_audience_score = this.uniformize( data.audience_score, dataTmp.audienceMetadata?.bucketsMap, request, "audience" ); data.uniformized_cta_score = this.uniformize( data.cta_score, dataTmp.ctaMetadata?.bucketsMap, request, "cta" ); // Scores composition data.composite_score = OrderingStep.getCompositeScore( compositeScoreStrategy, weights, data.uniformized_context_score, data.uniformized_audience_score, data.uniformized_cta_score ); }); } static getCompositeScore( strategy: CompositeScoreStrategy, weights: Weights, context?: number | null, audience?: number | null, cta?: number | null ): number { switch (strategy) { // Random score case CompositeScoreStrategy.RANDOM: return Math.random(); case CompositeScoreStrategy.NORMAL: default: { let { cta: ctaWeight, audience: audienceWeight } = weights; // Weighted sum composition let contextWeight = 1 - audienceWeight - ctaWeight; // If a score is null, we set its value and weight to 0, // such that it has no impact in the weighted sum. // Note that the total weight can now be lower than 1. if (context == null) { context = contextWeight = 0; } if (audience == null) { audience = audienceWeight = 0; } if (cta == null) { cta = ctaWeight = 0; } // If all weights are 0, we return a score of 0. const totalWeight = contextWeight + audienceWeight + ctaWeight; if (totalWeight === 0) { return 0; } // We scale all weights such that the total weight is 1. const scaleFactor = 1 / totalWeight; contextWeight *= scaleFactor; audienceWeight *= scaleFactor; ctaWeight *= scaleFactor; // We can now safely do the computation let score = context * contextWeight; score += audience * audienceWeight; score += cta * ctaWeight; return score; } } } public buildWeightsMap(request: ChoiceRequest): [weightsMap: WeightsMap, popIndex: number] { const paramInfo = this.featureConfigService.getParameterInfo( request, constants.COMPOSITE_SCORE_WEIGHTS_PARAM, "" ); const weightsStr = paramInfo?.value; const defaultMap: WeightsMap = { default: { cta: 0, audience: 0 } }; if (!weightsStr) { return [defaultMap, -1]; } try { const weightsMap = JSON.parse(weightsStr) as WeightsMap; // make sure weights sum doesn't go above 1 for (const entry in weightsMap) { const weights = weightsMap[entry]; weights.cta ??= 0; weights.audience ??= 0; const tot = _.reduce(weights, (sum, v) => sum + v, 0); if (tot > 1) { logger.warn(`Some of composition weights for entry ${entry} > 1`); return [defaultMap, -1]; } } return [weightsMap, paramInfo?.popIndex ?? -1]; } catch (err) { logger.warn( `Invalid value for Parameter ${constants.COMPOSITE_SCORE_WEIGHTS_PARAM} in feature-config` ); return [defaultMap, -1]; } } private getWeights( campaignCreative: CampaignCreative, campaignData: CampaignData, weightsMap: WeightsMap ): [weights: Weights, strategy: string] { if (!campaignCreative.metadataTmp.economicType || !campaignCreative.metadataTmp.adType) { const adAndEcoType = this.campaignModel.getAdAndEcoType( campaignData, campaignCreative.creativeId ); campaignCreative.metadataTmp.economicType = adAndEcoType.economicType; campaignCreative.metadataTmp.adType = adAndEcoType.adType; } const ecoType = campaignCreative.metadataTmp.economicType; const adType = campaignCreative.metadataTmp.adType; const adAndEcoTypeKey = `${ecoType}-${adType}`; let weight: Weights = weightsMap.default; let strategy = "default"; if (adAndEcoTypeKey in weightsMap) { weight = weightsMap[adAndEcoTypeKey]; strategy = adAndEcoTypeKey; } else if (ecoType in weightsMap) { weight = weightsMap[ecoType]; strategy = ecoType; } return [weight, strategy]; } private uniformize( score: number | null | undefined, bucketMap: BucketMapping[] | null | undefined, request: ChoiceRequest, scoreType: string ) { this.metricService.incCounterWithReq(request, SCORE_UNIFORMIZATION_COUNTER, 1, { score_type: scoreType, num_buckets: bucketMap?.length || 0, }); return OrderingStep.uniformize(score, bucketMap); } static uniformize( score: number | null | undefined, bucketMap: BucketMapping[] | null | undefined ) { if (score == null) { return null; } // If no mapping, draw random score between 0 and 1 if (!bucketMap || bucketMap.length === 0) { return Math.random(); } // Find bucket assigned to this score let bucketIndex = bisectRight( bucketMap.map((b) => b.inputUpper), score ); // If score >= 1, we get incorrect value bucketIndex = bucketMap.length bucketIndex = Math.min(bucketIndex, bucketMap.length - 1); const bucket = bucketMap[bucketIndex]; // Draw random score inside assigned bucket return Math.random() * (bucket.outputUpper - bucket.outputLower) + bucket.outputLower; } getCampaignFeatures(request: ChoiceRequest, campaignCreative: CampaignCreative) { const campaignData = request.campaignData[campaignCreative.campaign.id]; const creative = campaignData.creatives?.find((cr) => campaignCreative.creativeId === cr.id); return { economicType: campaignData.economic_type, format: getFormatFromId(creative?.format?.id?.toString()), budget: campaignData.budget || null, managed: this.campaignModel.isProgrammatic(campaignData) ? 0 : 1, advertiserId: campaignData.advertiser?.id?.toString() || "-1", adType: creative?.format?.ad_type, }; } /** * log campaigns result. Only for non-prod env * @private */ private log(campaignCreatives: CampaignCreative[]) { campaignCreatives.forEach((campaignCreative) => { logger.debug(() => ({ message: "Campaign creative parameters", ..._.pick(campaignCreative, [ "campaign.id", "creativeId", "metadata.acc_rate", "metadata.audience_score", "metadata.cta_score", "metadata.composite_score", ]), })); }); } private sort(request: ChoiceRequest, campaignCreatives: CampaignCreative[]) { const sortStrategy = this.featureConfigService.getStringValue( request, "ms-choices.campaign-sort-strategy", SortStrategy.INCOME ); logger.debug(() => `campaign sort strategy: ${sortStrategy}`); request.metadata.sort_strategy = sortStrategy === "RANDOM" ? SortStrategy.RANDOM : SortStrategy.INCOME; this.metricService.incCounterWithReq(request, SORT_CAMPAIGNS_COUNTER, 1, { strategy: sortStrategy, }); const randomCompare = () => { return Math.random() > 0.5 ? 1 : -1; }; const incomeCompare = (first: CampaignCreative, second: CampaignCreative) => { const a: number = this.getIncome(request, first); const b: number = this.getIncome(request, second); if (a === b) { // sort randomly when score is equal return randomCompare(); } return a > b ? -1 : 1; }; switch (sortStrategy.toUpperCase()) { case SortStrategy.RANDOM: campaignCreatives.sort(randomCompare); break; case SortStrategy.INCOME: default: campaignCreatives.sort(incomeCompare); break; } } private async timer(request: ChoiceRequest, stage: string, fn: Function) { const end = this.metricService.startTimerWithReq(request, ORDERING_STAGE_HISTOGRAM); await fn(); end({ stage }); } countScorePresence(request: ChoiceRequest, campaignCreatives: CampaignCreative[]): void { type ScoreName = "cta_score" | "audience_score" | "context_score"; const scores: ScoreName[] = ["cta_score", "audience_score", "context_score"]; for (const scoreName of scores) { for (const creative of campaignCreatives) { let isPresent = creative.metadata[scoreName] != null; if (scoreName === "audience_score" && creative.metadataTmp.audienceMetadata?.fallback) { isPresent = false; } this.metricService.incCounterWithReq(request, SCORE_PRESENCE_COUNTER, 1, { score_name: scoreName, present: `${isPresent}`, }); } } } } export default OrderingStep;