|
|
@@ -0,0 +1,212 @@
|
|
|
+package com.tzld.piaoquan.ad.engine.service.predict.v2;
|
|
|
+
|
|
|
+import com.ctrip.framework.apollo.spring.annotation.ApolloJsonValue;
|
|
|
+import com.tzld.piaoquan.ad.engine.commons.enums.RedisPrefixEnum;
|
|
|
+import com.tzld.piaoquan.ad.engine.commons.redis.AdRedisHelper;
|
|
|
+import com.tzld.piaoquan.ad.engine.service.feature.Feature;
|
|
|
+import com.tzld.piaoquan.ad.engine.service.feature.FeatureService;
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
+import org.apache.commons.collections4.CollectionUtils;
|
|
|
+import org.apache.commons.lang3.StringUtils;
|
|
|
+import org.springframework.beans.factory.annotation.Autowired;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+
|
|
|
+import java.util.HashMap;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 基于 ROR的广告预测策略
|
|
|
+ * <p>
|
|
|
+ * 核心逻辑:
|
|
|
+ * 1. 根据用户的历史行为特征(启动次数launchs、留存率ror、人群分层ad_level)计算展示广告的概率阈值
|
|
|
+ * 2. 通过 mid 的 hash 值生成伪随机分数
|
|
|
+ * 3. 如果分数 <= 阈值,则展示广告;否则不展示
|
|
|
+ * <p>
|
|
|
+ * 用于控制不同用户群体的广告曝光频率,实现精细化运营
|
|
|
+ */
|
|
|
+@Slf4j
|
|
|
+@Service
|
|
|
+public class PredictStrategyByRor extends BasicPredict {
|
|
|
+
|
|
|
+ /** 特征服务,用于获取用户行为特征 */
|
|
|
+ @Autowired
|
|
|
+ private FeatureService featureService;
|
|
|
+
|
|
|
+ /** Redis 客户端,用于获取基于用户特征的概率配置 */
|
|
|
+ @Autowired
|
|
|
+ private AdRedisHelper adRedisHelper;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Apollo 动态配置:根据 rootSessionId 尾号和 appType 进行流量分桶
|
|
|
+ * <p>
|
|
|
+ * 配置格式示例:
|
|
|
+ * <pre>
|
|
|
+ * [
|
|
|
+ * {
|
|
|
+ * "appType": ["0", "3"],
|
|
|
+ * "tail": ["0", "1", "2"],
|
|
|
+ * "config": {"default_probability": 0.5}
|
|
|
+ * }
|
|
|
+ * ]
|
|
|
+ * </pre>
|
|
|
+ */
|
|
|
+ @ApolloJsonValue("${experiment.ror.root.session.id.tail.config:[]}")
|
|
|
+ private List<RootSessionIdTailConfigItem> configItems;
|
|
|
+
|
|
|
+ private static final String TABLE_NAME = "alg_mid_history_behavior_1month";
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 策略名称标识
|
|
|
+ */
|
|
|
+ @Override
|
|
|
+ public String name() {
|
|
|
+ return "launch_layer_ror";
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 核心预测方法:决定是否向用户展示广告
|
|
|
+ *
|
|
|
+ * @param ctx 预测上下文,包含 mid、appType、rootSessionId 等信息
|
|
|
+ * @return 预测结果 Map,包含:
|
|
|
+ * - ad_predict: 1=不展示广告,2=展示广告
|
|
|
+ * - score: 用户的伪随机分数
|
|
|
+ * - threshold: 广告展示概率阈值
|
|
|
+ * - launchs/ror/ad_level: 用户行为特征
|
|
|
+ * - 返回 null 表示跳过该策略
|
|
|
+ */
|
|
|
+ @Override
|
|
|
+ public Map<String, Object> predict(PredictContext ctx) {
|
|
|
+
|
|
|
+ try {
|
|
|
+ String rootSessionId = ctx.getRootSessionId();
|
|
|
+
|
|
|
+ // 前置校验:配置为空或 rootSessionId 为空时,返回 null(跳过该策略)
|
|
|
+ if (CollectionUtils.isEmpty(configItems) || StringUtils.isBlank(rootSessionId)) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ String appType = ctx.getAppType();
|
|
|
+
|
|
|
+ // 获取默认概率阈值(基于 rootSessionId 尾号和 appType 匹配配置)
|
|
|
+ Double defaultProbability = getDefaultProbability(rootSessionId, appType);
|
|
|
+
|
|
|
+ // 用户不在实验分桶内,跳过该策略
|
|
|
+ if (defaultProbability == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ Map<String, Object> rtnMap = new HashMap<>();
|
|
|
+
|
|
|
+ // 用户行为特征变量(来自离线特征表 alg_mid_history_behavior_1month)
|
|
|
+ String launchs = null; // 启动次数分桶(如 "0-5", "5-10" 等)
|
|
|
+ String ror = null; // 留存率分桶
|
|
|
+ String adLevel = null; // 广告等级(用户对广告的敏感度分层)
|
|
|
+
|
|
|
+ // 根据 mid 获取用户近一个月的历史行为特征
|
|
|
+ Feature feature = featureService.getMidBehaviorFeature(TABLE_NAME, ctx.getMid());
|
|
|
+
|
|
|
+ // 安全地提取特征值(多层 null 检查)
|
|
|
+ if (feature != null && feature.getUserFeature() != null && feature.getUserFeature().get("") != null) {
|
|
|
+ Map<String, String> algMidHistoryBehavior1month = feature.getUserFeature().get(TABLE_NAME);
|
|
|
+ launchs = algMidHistoryBehavior1month.get("launchs");
|
|
|
+ ror = algMidHistoryBehavior1month.get("ror");
|
|
|
+ adLevel = algMidHistoryBehavior1month.get("ad_level");
|
|
|
+ }
|
|
|
+
|
|
|
+ // 计算最终的广告展示概率阈值
|
|
|
+ // 优先使用 Redis 中基于用户特征的精细化阈值,否则使用默认阈值
|
|
|
+ double showAdProbability = getShowAdProbability(launchs, ror, adLevel, defaultProbability);
|
|
|
+
|
|
|
+ // 基于 mid 的 hash 值生成 [0, 1) 范围内的伪随机分数
|
|
|
+ // 同一个 mid 在同一小时内(RandW 每小时更新)会得到相同的分数
|
|
|
+ double score = this.calcScoreByMid(ctx.getMid());
|
|
|
+
|
|
|
+ // 核心决策逻辑:分数 <= 阈值 → 展示广告
|
|
|
+ if (score <= showAdProbability) {
|
|
|
+ // 展示广告,ad_predict = 2
|
|
|
+ rtnMap.putAll(rtnAdPredict(ctx));
|
|
|
+ rtnMap.put("model", this.name());
|
|
|
+ } else {
|
|
|
+ // 不展示广告,ad_predict = 1
|
|
|
+ rtnMap.putAll(rtnNoAdPredict(ctx));
|
|
|
+ rtnMap.put("no_ad_strategy", this.name());
|
|
|
+ }
|
|
|
+
|
|
|
+ // 记录决策相关的特征和参数,用于日志分析和效果追踪
|
|
|
+ rtnMap.put("score", score);
|
|
|
+ rtnMap.put("threshold", showAdProbability);
|
|
|
+ rtnMap.put("launchs", launchs);
|
|
|
+ rtnMap.put("ror", ror);
|
|
|
+ rtnMap.put("ad_level", adLevel);
|
|
|
+ return rtnMap;
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("[PredictStrategyByRor] predict error, ctx: {}", ctx, e);
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取广告展示概率阈值
|
|
|
+ * <p>
|
|
|
+ * 策略:根据用户的 (ad_level, launchs, ror) 组合从 Redis 查询对应的概率值
|
|
|
+ * 如果查询失败或无数据,则使用默认概率
|
|
|
+ *
|
|
|
+ * @param launchs 启动次数分桶
|
|
|
+ * @param ror 留存率分桶
|
|
|
+ * @param ad_level 人群分层
|
|
|
+ * @param defaultProbability 默认概率(兜底值)
|
|
|
+ * @return 广告展示概率阈值 [0, 1]
|
|
|
+ */
|
|
|
+ private double getShowAdProbability(String launchs, String ror, String ad_level, Double defaultProbability) {
|
|
|
+ // 任一特征为空,使用默认概率
|
|
|
+ if (StringUtils.isAnyBlank(launchs, ror, ad_level)) {
|
|
|
+ return defaultProbability;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ // 构建 Redis key:格式为 "ad_level_launchs_ror",例如 "有转化_10_0"
|
|
|
+ String keyId = ad_level + "_" + launchs + "_" + ror;
|
|
|
+ String key = String.format(RedisPrefixEnum.AD_USER_ROR_BEHAVIOR.getPrefix(), keyId);
|
|
|
+
|
|
|
+ // 从 Redis 获取概率值
|
|
|
+ String probability = adRedisHelper.get(key);
|
|
|
+
|
|
|
+ // 解析概率值,如果为 null 则使用默认值
|
|
|
+ return probability == null ? defaultProbability : Double.parseDouble(probability);
|
|
|
+ } catch (Exception e) {
|
|
|
+ // 解析失败(如非数字字符串)或 Redis 异常,记录错误并使用默认值
|
|
|
+ log.error("getShowAdProbability error, launchs: {}, ror: {}, ad_level: {}, e = ", launchs, ror, ad_level, e);
|
|
|
+ return defaultProbability;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取默认概率阈值
|
|
|
+ * <p>
|
|
|
+ * 根据 rootSessionId 的最后一位字符(尾号)和 appType 匹配配置,用于流量分桶实验
|
|
|
+ *
|
|
|
+ * @param rootSessionId 根会话 ID
|
|
|
+ * @param appType 应用类型
|
|
|
+ * @return 默认概率,如果不匹配任何配置则返回 null
|
|
|
+ */
|
|
|
+ private Double getDefaultProbability(String rootSessionId, String appType) {
|
|
|
+ // 前置校验
|
|
|
+ if (CollectionUtils.isEmpty(configItems) || StringUtils.isAnyBlank(rootSessionId) || appType == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 提取 rootSessionId 的最后一个字符作为尾号,用于流量分桶
|
|
|
+ String tail = rootSessionId.substring(rootSessionId.length() - 1);
|
|
|
+
|
|
|
+ // 遍历配置项,查找同时匹配 appType 和尾号的配置
|
|
|
+ for (RootSessionIdTailConfigItem item : configItems) {
|
|
|
+ if (item.getAppType().contains(appType) && item.getTail().contains(tail)) {
|
|
|
+ return item.getConfig().get("default_probability");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 未匹配到任何配置
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+}
|