【Python】聚类算法应用 -- 广告投放效果的离线评估

来源:互联网 发布:jessyline姐妹品牌js 编辑:程序博客网 时间:2024/05/16 15:37

简要说明

同样是在实习期间做的,由于公司去年在广告的投放上高达10亿!!(黑脸=_=!),其中SEM的投放占比不小,投了四个:baidu、360、搜狗和神马,其中前三个是WAP和PC端都有投,神马只投了WAP端。所以我想对历史投放效果数据进行一下挖掘分析,看是否能找出一些有价值的东西,降低投放成本。昨天参加的一个数据分析峰会记得一嘉宾也这样说过:数据挖掘与分析的最重要的不是你的报表、图表多么得漂亮,而是你挖掘出的规律或结果能为公司业务带来多少价值。
数据 – 挖掘与分析 – 价值!


调用SQL获取所需数据

这里,我写了一个类以获取相应的数据。

  • city:传入城市名称
  • start_time:传入所需数据集的开始时间
  • end_time:传入所需数据集的结束时间
  • platform:传入平台id(1为PC,2为WAP),default value=None,即数据集是获取汇总数据
    engine_type:传入引擎id(1为百度、2为搜狗、3为360、4为神马), default value=None,即数据集是获取汇总的数据

OK,Talk is easy,show the code!我直接贴上我的代码吧:

# coding:utf-8import pandas as pdimport pymysql, datetimefrom pandas.io.sql import read_sqlclass AD_Statistics_Select:    def __init__(self, city, start_time, end_time, platform=None, engine_type=None):        self.city = city        self.start_time = start_time        self.end_time = end_time        self.platform = platform        self.engine_type = engine_type        self.df = self.SqlToDataframe()        self.keyword_list = self.df['keyword']    def SqlToDataframe(self):        ## 连接到数据库        # 这里我隐去了详细的sql的host、用户、密码等详细信息,望大家理解    into_db = ['host', 'user','password', 'database', 'port', 'charset']        cnxn = pymysql.connect(host=into_db[0], user=into_db[1],                               passwd=into_db[2], db=into_db[3],                               port=int(into_db[4]), charset=into_db[5])        # 下面我也隐去了表名,请大家理解哈~        if self.platform == None and self.engine_type == None:            sql = "SELECT  keyword, COUNT(DISTINCT log_date) AS date_count, SUM(present) AS present_total, \               SUM(click) AS click_total, SUM(click)/SUM(present) AS CTR,  SUM(uv) AS uv_total, \               AVG(IF(price>0, price, NULL)) AS price_avg, AVG(replace(position,'\r','')) AS position_avg, \               COUNT(DISTINCT IF(click>0,log_date,NULL)) AS clicked_num \               FROM *表名*  \               WHERE log_date BETWEEN '{}' AND '{}' \               AND city = '{}' \               GROUP BY 1 \               ORDER BY 1".format(self.start_time, self.end_time, self.city)        ## 大家注意一下 ##        ## 在SQL查询语句这块,由于多行存在,文章可能会显示的语法格式有些错误,但是在编辑器中是没问题的!##        if self.platform != None and self.engine_type == None:            sql = "SELECT  keyword, COUNT(DISTINCT log_date) AS date_count, SUM(present) AS present_total, \               SUM(click) AS click_total, SUM(click)/SUM(present) AS CTR,  SUM(uv) AS uv_total, \               AVG(IF(price>0, price, NULL)) AS price_avg, AVG(replace(position,'\r','')) AS position_avg, \               COUNT(DISTINCT IF(click>0,log_date,NULL)) AS clicked_num\               FROM *表名*  \               WHERE log_date BETWEEN '{}' AND '{}' \               AND city = '{}' \               AND platform = '{}' \               GROUP BY 1 \               ORDER BY 1".format(self.start_time, self.end_time, self.city, self.platform)        if self.platform == None and self.engine_type != None:            sql = "SELECT  keyword, COUNT(DISTINCT log_date) AS date_count, SUM(present) AS present_total, \               SUM(click) AS click_total, SUM(click)/SUM(present) AS CTR,  SUM(uv) AS uv_total, \               AVG(IF(price>0, price, NULL)) AS price_avg, AVG(replace(position,'\r','')) AS position_avg, \               COUNT(DISTINCT IF(click>0,log_date,NULL)) AS clicked_num\               FROM *表名*  \               WHERE log_date BETWEEN '{}' AND '{}' \               AND city = '{}' \               AND engine_type = '{}' \               GROUP BY 1 \               ORDER BY 1".format(self.start_time, self.end_time, self.city, self.engine_type)        if self.platform != None and self.engine_type != None:            sql = "SELECT  keyword, COUNT(DISTINCT log_date) AS date_count, SUM(present) AS present_total, \               SUM(click) AS click_total, SUM(click)/SUM(present) AS CTR,  SUM(uv) AS uv_total, \               AVG(IF(price>0, price, NULL)) AS price_avg, AVG(replace(position,'\r','')) AS position_avg, \               COUNT(DISTINCT IF(click>0,log_date,NULL)) AS clicked_num\               FROM *表名*  \               WHERE log_date BETWEEN '{}' AND '{}' \               AND city = '{}' \               AND platform = '{}' \               AND engine_type = '{}'  \               GROUP BY 1 \               ORDER BY 1".format(self.start_time, self.end_time, self.city, self.platform, self.engine_type)    ## 使用pd接口一步到位啊!! ##    try:        frame = read_sql(sql, cnxn)    except Exception:        frame = pd.DataFrame([])    return frame

这里只提醒一点的是:
由于后面会对数据进行很多操作,所以SQL查询获得的数据集直接利用pd的read_sql(刚开始我还自己傻傻地获取,特地增加了一些处理才使之转成dataframe)得到dataframe。所以啊,好好利用大神们已经造好的轮子很重要!


简单的描述以及展现函数

当然,这一步其实是最基本的数据特征描述以及展现,但是从某些特征我们其实是可以获取很多有价值的信息的,所以这一步必不可少!

这里,我也写了一个类进行相应的数据处理,该类继承了AD_Statistics_Select,所有其父类的city、engine_type、platform等信息是全部继承下来的。

  • 对keyword按照CTR进行排序:
class AD_Statistics(AD_Statistics_Select):    def keyword_SortBy_CTR(self, keyword, detail=False, **kwargs):        """        输入需要查询的关键词,默认返回按照转化率排序得出的排序结果        以及转化率、平均单价、平均投放位置信息;        如果需要输出所有字段信息,则设置`detail=True`        param        ========        keyword:必须;输入的需要查询的关键词        detail:逻辑变量,默认为False;是否需要输出该关键词的所有字段信息        df:是否输入dataframe,默认为所有未筛选过的数据        return        ========        该关键词按照转化率的排序结果        """        if 'df' in kwargs.keys():            df = kwargs['df'].sort_values('CTR', ascending=False)        else:            df = self.df.sort_values('CTR', ascengding=False)        df.index = range(1, len(df) + 1)        if detail:            return df.loc[df['keyword'] == keyword, df.columns]        return df.loc[df['keyword'] == keyword, ['CTR', 'price_avg', 'position_avg']]
  • 对keyword按照present进行排序:
def keyword_SortBy_present(self, keyword, detail=False, **kwargs):    """    输入需要查询的关键词,默认返回按照展现次数排序得出的排序结果    以及转化率、平均单价、平均投放位置信息;    如果需要输出所有字段信息,则设置`detail=True`    param    ========    keyword:必须;输入的需要查询的关键词    detail:逻辑变量,默认为False;是否需要输出该关键词的所有字段信息    df:是否输入dataframe,默认为所有未筛选过的数据    return    ========    该关键词按照展现次数的排序结果    """    if 'df' in kwargs.keys():        df = kwargs['df'].sort_values('present_total', ascending=False)    else:        df = self.df.sort_values('present_total', ascending=False)    df.index = range(1, len(df) + 1)    if detail:        return df.loc[df['keyword'] == keyword, df.columns]    return df.loc[df['keyword'] == keyword, ['CTR', 'price_avg', 'position_avg']]
  • 对keyword按照click进行排序:
def keyword_SortBy_click(self, keyword, detail=False, **kwargs):    """    输入需要查询的关键词,默认返回按照点击次数排序得出的排序结果    以及转化率、平均单价、平均投放位置信息;    如果需要输出所有字段信息,则设置`detail=True`    param    ========    keyword:必须;输入的需要查询的关键词    detail:逻辑变量,默认为False;是否需要输出该关键词的所有字段信息    df:是否输入dataframe,默认为所有未筛选过的数据    return    ========    该关键词按照点击次数的排序结果    """    if 'df' in kwargs.keys():        df = kwargs['df'].sort_values('click_total', ascending=False)    else:        df = self.df.sort_values('click_total', ascengding=False)    df.index = range(1, len(df) + 1)    if detail:        return df.loc[df['keyword'] == keyword, df.columns]    return df.loc[df['keyword'] == keyword, ['CTR', 'price_avg', 'position_avg']]
  • 下面几个top函数不用看,其实对前面对应的函数返回值再取head即可:
def present_top(self, n=100):    """    返回展现量前n位的keyword数据,n默认为100    """    return self.df.sort_values('present_total', ascending=False).head(n)def click_top(self, n=100):    """    返回点击量前n位的keyword数据,n默认为100    """    return self.df.sort_values('click_total', ascending=False).head(n)def price_top(self, n=100):    """    返回price_avg前n位的keyword数据,n默认为100    """    return self.df.sort_values('price_avg', ascending=False).head(n)def date_count_top(self, n=100):    """    返回点击天数前n位的keyword数据,n默认为100    """    return self.df.sort_values('click_total', ascending=False).head(n)def CTR_top(self, n=100):    """    返回点击率前n位的keyword数据,n默认为100    """    return self.df.sort_values('CTR', ascending=False).head(n)
  • 由于数据很稀疏,可以写个根据是否有click进行筛选的函数(可以不用看=_=!):
def click_none(self):    # 返回关键词里没有点击量的    return self.df[self.df['click_total'] == 0]def click_not_none(self):    # 返回关键词里有点击量的    return self.df[self.df['click_total'] != 0]### 简单展现一下 ###def show_click_none(self):    show_df = self.click_none()    show_df['present_avg'] =    show_df['present_total'] / show_df['date_count']    p = ggplot(aes(x='date_count',y='present_avg'), data=show_df) + geom_point()    p.show()def show_click_not_none(self):    #show_df = self.click_top()    show_df = self.click_not_none()    show_df['present_avg']  =   show_df['present_total'] / show_df['date_count']    p = ggplot(aes(x='date_count',y='present_avg'), data=show_df) + geom_point()    p.show()
  • 这里我再补充一下,我获取的训练数据的具体参数信息:

    • start_time:2017-05-01
    • end_time:2017-07-20
    • platform:汇总数据
    • engine_type:汇总数据
    • city:我选择的是成交量前十的城市
  • 我大概说一下,从keyword的click数据就可以看出,每个city的keyword大概在八、九千,有的在1万以上,大概每个城市累积贡献80%点击量以上的只有少量关键词(100来词,甚至只有几十个词)。不信?数据说话:

def click_part(self, before=0.81):    # 返回一个tuple    # 点击量before前的dataframe,before到1.0的dataframe,1.0(也即无点击的)的dataframe    click_df = self.df.sort_values('click_total', ascending=False)    add_rate = []    s = click_df['click_total'].sum()    for i in range(1, len(click_df)+1):        add_rate.append(click_df['click_total'][0:i].sum() / s)    click_df['c_add_rate'] = add_rate    #click_df['rate'] = click_df['click_total'] / click_df['click_total'].sum()    #click_df['rank'] = range(1, len(click_df)+1)    #click_df.head(200).plot('rank', 'add_rate', kind='line')    return click_df[click_df['c_add_rate'] < before], click_df[(click_df['c_add_rate']>0.81)&(click_df['c_add_rate']<1.0)], click_df[click_df['c_add_rate'] == 1.0]

由于现在我关注的是贡献最多点击的那些词(其实市场部那边也是这样回复我的,他们着重关注的是前面的那些重点词~),而SEM这块是按照点击收费的,故在点击成本这块儿,点击越多,公司其竞价广告付费也越多。所以我分析的是累积贡献了80%的那些词。


指标构建

为了分析竞价price与最终点击率CTR之间的相关关系,我引入了一个中间变量–position;其实我们可以直观感受到,price越高,position值越大(position值越大,越靠后),而position越大,其带来的CTR越低。所以我构建了price-position皮尔逊系数以及position-CTR皮尔逊系数。

  • 为了得到keyword所对应的皮尔逊系数值,我在类AD_Statistics_Select中添加了一个获取keyword详细信息的函数keyword_detail():
def keyword_detail(self, keyword):    # 如上,隐去了SQL连接相关信息    into_db = []    cnxn = pymysql.connect(host=into_db[0], user=into_db[1],                           passwd=into_db[2], db=into_db[3],                           port=int(into_db[4]), charset=into_db[5])    ##  同样,大家注意一下 ##    ##  这块儿SQL查询语句显示出来可能会出现语法错误,但在编辑器中是没问题的  ##    if self.platform == None and self.engine_type == None:        sql = "SELECT  keyword, CAST(REPLACE(position, '\r', '') AS DECIMAL(4,2)) AS position, click/present AS CTR , price \               FROM *表名*  \               WHERE log_date BETWEEN '{}' AND '{}' \               AND city = '{}'  \               AND click <> 0   \               AND present <> 0  \               AND keyword = '{}'  \               AND click < present \               ORDER BY 1".format(self.start_time, self.end_time, self.city, keyword)    if self.platform != None and self.engine_type == None:        sql = "SELECT  keyword, CAST(REPLACE(position, '\r', '') AS DECIMAL(4,2)) AS position, click/present AS CTR , price \             FROM *表名* \             WHERE log_date BETWEEN '{}' AND '{}' \             AND city = '{}'  \             AND click <> 0   \             AND present <> 0  \             AND platform = '{}' \             AND keyword = '{}'  \             AND click < present \             ORDER BY 1".format(self.start_time, self.end_time, self.city, self.platform, keyword)    if self.platform == None and self.engine_type != None:        sql = "SELECT  keyword, CAST(REPLACE(position, '\r', '') AS DECIMAL(4,2)) AS position, click/present AS CTR , price \               FROM *表名*  \               WHERE log_date BETWEEN '{}' AND '{}' \               AND city = '{}'  \               AND click <> 0   \               AND present <> 0  \               AND engine_type = '{}' \               AND keyword = '{}'  \               AND click < present \               ORDER BY 1".format(self.start_time, self.end_time, self.city, self.engine_type, keyword)    if self.platform != None and self.engine_type != None:        sql = "SELECT  keyword, CAST(REPLACE(position, '\r', '') AS DECIMAL(4,2)) AS position, click/present AS CTR , price \               FROM *表名*  \               WHERE log_date BETWEEN '{}' AND '{}' \               AND city = '{}'  \               AND click <> 0   \               AND present <> 0  \               AND platform = '{}' \               AND engine_type =  '{}'  \               AND keyword = '{}'  \               AND click < present \               ORDER BY 1".format(self.start_time, self.end_time, self.city, self.platform,self.engine_type, keyword)    try:        keyword_detail_frame = read_sql(sql, cnxn)    except Exception:        keyword_detail_frame = pd.DataFrame([])    return keyword_detail_frame
  • 获取到keyword详细信息之后,由于在SQL 查询中已经对异常的click、present以及position值进行了筛选,故我们直接构建其皮尔逊相关系数:
def pearson_points(self, keyword_df):    # 返回keyword的position-price的皮尔逊相关系数以及对应的P值,position-CTR的皮尔逊相关系数以及对应的P值,price-CTR的皮尔逊相关系数以及对应的P值    grouped_by_position = keyword_df.groupby('position')  # groupby    avg_grouped_by_position = grouped_by_position['CTR', 'price'].agg([np.mean])  # 求均值    avg_grouped_by_position['position'] = avg_grouped_by_position.index  # 加index    array_position = np.array(avg_grouped_by_position['position'])    array_price = np.array(avg_grouped_by_position['price']).reshape(len(array_position), )    array_CTR = np.array(avg_grouped_by_position['CTR']).reshape(len(array_position), )    p_p = pearsonr(array_position, array_price)    po_C = pearsonr(array_position, array_CTR)    pr_C = pearsonr(array_price, array_CTR)    return [keyword_df['keyword'][0], p_p[0], p_p[1], po_C[0], po_C[1], pr_C[0], pr_C[1]]
  • 同时,可以增加一个展现函数,展现keyword的price-position信息、position-CTR信息以及最终要求的price-CTR信息:
def keyword_show(self, keyword_df):    """    根据keyword绘出其price-position、CTR-position以及price-CTR的关系图    """    grouped_by_position = keyword_df.groupby('position')       # groupby    avg_grouped_by_position = grouped_by_position['CTR', 'price'].agg([np.mean])  # 求均值    avg_grouped_by_position['position'] = avg_grouped_by_position.index    array_position = np.array(avg_grouped_by_position['position'])    array_price = np.array(avg_grouped_by_position['price']).reshape(len(array_position), )    array_CTR = np.array(avg_grouped_by_position['CTR']).reshape(len(array_position), )    fig = plt.figure()    fig.suptitle(u'%s' % keyword_df['keyword'][0], fontproperties=font)    ax1 = fig.add_subplot(2, 2, 1)    p_p = pearsonr(array_position, array_price)    ax1.set_title(u'pearson系数:%s,伴随P值:%s' % (p_p[0], p_p[1]), fontproperties=font)    ax2 = fig.add_subplot(2, 2, 3)    po_C = pearsonr(array_position, array_CTR)    ax2.set_title(u'pearson系数:%s,伴随P值:%s' % (po_C[0], po_C[1]), fontproperties=font)    ax3 = fig.add_subplot(1, 2, 2)    pr_C = pearsonr(array_price, array_CTR)    ax3.set_title(u'pearson系数:%s,伴随P值:%s' % (pr_C[0], pr_C[1]), fontproperties=font)    avg_grouped_by_position.plot(x='position', y='price', kind='scatter', ax=ax1, figsize=(12, 6),                                 alpha=0.5, color='#33CC99', sharex=True)    avg_grouped_by_position.plot(x='position', y='CTR',   kind='scatter', ax=ax2, figsize=(12, 6),                                 alpha=0.5, color='#FF3366')    avg_grouped_by_position.plot(x='price', y='CTR', kind='scatter', ax=ax3, alpha=0.5, color='#FF4500')    return plt
  • 遍历展现:
def keyword_list_show(self, keyword_list):    # yield返回在list中每一个keyword的可视化图    for i in keyword_list:        keyword_df = self.keyword_detail(i)        yield self.keyword_show(keyword_df)
  • 我们已经定义了对每个keyword求其对应pearson系数的函数,接下来就是获取累积贡献了80%点击的那些词的对应系数。比较简单,求得一个keywords list,遍历即可:
def keyword_list_pearson(self, keyword_list):    # 返回list中每一个keyword的详细pearson系数及伴随P值信息    result = []    for i in keyword_list:        keyword_df = self.keyword_detail(str(i.encode('utf-8')))        result.append(self.pearson_points((keyword_df)))    pp_df = pd.DataFrame(result)    pp_df.columns = [u'keyword', u'position-price_pearson', u'position-price_pvalue', u'position-CTR_pearson', u'position-CTR_pvalue', u'price-CTR_pearson', u'price-CTR_pvalue']    # pp_df.to_csv('keywords_pearson_point/%s-pearson.csv') % self.city    return pp_df

K-Means算法训练

得到了所需keywords的pearson系数相关信息后,接下来最重要的一步便是模型的构建及训练。由于keyword是无标签的,所以我采用了无监督学习中最典型的K-Means聚类算法进行训练,并给出其最终分类结果。

  • 首先,对这些词进行聚类算法分析的两个维度是:price-position pearson相关系数和position-CTR pearson相关系数。

  • 其次,最重要的一步便是K值的确定,根据博文http://blog.csdn.net/buracag_mc/article/details/75727895中所介绍的方法,我们根据肘部法则确定的K值为4,K=4也是符合我们的直观感受的。其实我们不妨大胆猜测一下分类结果:

    • 第一类:price-position和position-CTR pearson系数均为负,这类是跟我们主观感受相符合的,应该大部分集中在这儿;

    • 第二类:price-position和position-CTR pearson系数均为正,这类完全与我们主观感受不相符合的,数量应该很少;

    • 第三、四类:price-position和position-CTR pearson系数一正一负,这类词是我们分析的目标词,需要尝试进行竞价调整的;

  • code

def keyword_pearson_kmeans(self, keyword_list):    ## 聚类训练    ## 将结果可视化    pp_df = self.keyword_list_pearson(keyword_list)    df_train = np.array(pp_df[['position-price_pearson', 'position-CTR_pearson']])    kmeans_model = KMeans(n_clusters=4).fit(df_train)    #  print kmeans_model.labels_:每个点对应的标签值    colors = ['#33CC99', '#FF3366', '#FF4500', '#00BFFF']   # 颜色    markers = ['o', 's', 'D', 'v']                          # 数据标识    plt.figure(figsize=(16, 8))    ## 将每个点画出    for i, l in enumerate(kmeans_model.labels_):        plt.plot(df_train[i][0], df_train[i][1], color=colors[l],                 marker=markers[l], ls='None', alpha=0.5)        plt.text(df_train[i][0], df_train[i][1], '%s' % pp_df['keyword'][i], fontproperties=font, fontsize=6)    plt.title(u'K = 4, 轮廓系数 = %.03f' %                  metrics.silhouette_score(df_train, kmeans_model.labels_, metric='euclidean')                  , fontproperties=font)    plt.xlabel(u'position-price皮尔逊系数', fontproperties=font)    plt.ylabel(u'position-CTR皮尔逊系数', fontproperties=font)    plt.savefig('ad_kmeans1.png', dpi=500)    plt.show()

训练结果

OK,最终训练结果怎样,我们贴上图表说话:

if __name__ == "__main__":    '''    ## 交互输入 ##    city = raw_input(u'请输入需要查询的城市:')    start_time = raw_input(u'请输入开始时间:')    end_time = raw_input(u'请输入结束时间:')    f_p = lambda a: None if a == '' else a    platform = f_p(raw_input(u"""请输入平台id:            其中,1为PC,2位WAP;直接enter则默认为所有平台"""))    f_e = lambda a: None if a == '' else a    engine_type = f_e(raw_input(u"""请输入搜索引擎id:            其中,1为baidu,2为sougou,3360,4为shenma;直接enter则默认为所有搜索引擎"""))    '''

对深圳市的训练结果

接下来是多图预警啊!!

    AD_STA = AD_Statistics('深圳', '2017-05-01', '2017-07-20')    df = AD_STA.SqlToDataframe()    ret = AD_STA.click_part()    print AD_STA.keyword_pearson_kmeans(ret[0]['keyword'])

这里写图片描述

这里我上传的图片看起来可能不是很清楚,但不妨碍我们从中获取到的重要信息,关键词聚成了四类,是比较符合刚才的猜测的

第一类,price-position和position-CTR pearson系数均为负,包含了大部分广告关键词。第二类:price-position和position-CTR pearson系数均为正,仅有少量广告关键词;第三、四类:price-position和position-CTR pearson系数一正一负,这类词是我们分析的目标词,需要尝试进行竞价调整的;

我们再看一下,最后两类词到底有哪些:

  • price-position pearson系数为正,position-CTRpearson系数为负
    这里写图片描述
# 前面定义了一个函数'''def keyword_list_show(self, keyword_list):    # yield返回在list中每一个keyword的可视化图    for i in keyword_list:        keyword_df = self.keyword_detail(i)        yield self.keyword_show(keyword_df)'''    # 构建一个keyword的list即可    # keyword_list = ['', '', '',...]    pictures = AD_STA.keyword_list_show(keyword_list)    for i in pictures:        print i.show()

再看各个词的详细表现:
这里写图片描述
这里写图片描述

这里写图片描述
这里写图片描述
这里写图片描述
可以看到,这类词的price与CTR之间是不呈现正相关关系的,甚至有的词是存在着负相关关系!所以我们可以对这部分词降低其竞价,验证是否对CTR有显著性影响

  • price-position pearson系数为负,position-CTRpearson系数为正

这里写图片描述

再看各个词的详细表现:
这里写图片描述
这里写图片描述
这里写图片描述

对重庆市的训练

由于篇幅所限,下面我不再赘述,仅对每种情况输出一张图以示说明即可。
这里写图片描述

  • price-position pearson系数为正,position-CTRpearson系数为负
    这里写图片描述
  • price-position pearson系数为负,position-CTRpearson系数为正
    这里写图片描述

对成都市的训练结果

这里写图片描述
- price-position pearson系数为正,position-CTRpearson系数为负
这里写图片描述

  • price-position pearson系数为负,position-CTRpearson系数为正
    这里写图片描述