说明:原文链接:Machine Learning With AdWords Scripts And Google Prediction API

使用AdWords Scripts和Google Prediction API进行机器学习

在这篇文章中,专栏作者Russell Savage将会讲解如何结合AdWords Scripts和Google Prediction API来分析我们的PPC数据。

概述

大多数人在分析AdWords数据时,都会从AdWords下载一个巨大的csv文件,将它导入至Excel中进行各种计算并生成各种图表,然后试图通过分析这些数据去预测将来会发生什么变化。

这项分析工作非常困难且耗时,而且它还依赖于分析人员的个人经验和情感。机器学习可以帮助我们解决这些问题。今天,我们将为大家介绍如何用 Google Prediction API和AdWords Scripts来解决问题。

要求进行预测

有了Google Prediction API,你不再需要一个敬业的博士团队为你的pay-per-click (PPC) 数据构建和维护分析模型。你所需要做的就是格式化你的数据,然后将它们传给Google Prediction。这样你就可以要求它进行预测了。

大多数人面对机器学习时都会感到胆怯,不过我会教你如何快速入门。我首先要告诉你的是我从来没有参加过任何高级统计课程,也没有任何使用R语言编程的经验,但我可以轻松地使用Prediction API进行预测。你也一样。

首先我们思考下让我们的模型去预测什么。这里,我打算构建一个能够为我的Account(广告账户)预测出在某个气温、风速和天气状况下的平均CPC值的模型。当然,我们都知道天气会影响我们的竞价,但这个模型会告诉我们影响有多大。

收集历史数据

为了让我的模型能够进行预测,我必须用样本去教(或是训练)它,就像是教学生一样。也就是说,我必须为我的Adwords Account(广告账户)收集历史天气数据。这样,我的模型就能理解数据之间的关系,然后当我要求它进行预测时,它就会利用这些训练样本去返回预测结果。

在本文中,我们将编写两个Account(广告账户)级别的脚本。第一个脚本只是简单地为我们的Account(广告账户)收集和存储训练数据;第二个脚本将会利用这些训练数据去构建和更新模型。

训练数据由两部分组成:一部分是样本值,即预测结果返回值;另外一部分是一组特征值。在本例中,我们的样本值将会是某个地区的平均CPC;而特征值将会包括在那个时间点所有我们所知道的信息(这只是帮助读者掌握如何预测的例子,实际上我并没有所有数据)。

首先看看第一个脚本。我们需要各个地区的天气数据,所以我们需要一个函数帮助我们获取Geo Performance Report(地理营销报表)。我们可以从这些数据中知道我们的广告流量来自哪里。

当然,你也可以为某个特定的Campaign(广告系列)目标指定特定的地理位置,但是这样就没有乐趣了。下面这个函数可以帮助我们抓取每个地区的营销报表。

/*******************************
 * Grab the GEO_PERFORMANCE_REPORT for the given
 * date range. dateRange can be things like LAST_30_DAYS
 * or more specific like 20150101,20150201 
 *******************************/
function getGeoReport(dateRange) {
  // The columns are in a function so I can call them
  // later when I build the spreadsheet.
  var cols = getReportColumns();
  var report = 'GEO_PERFORMANCE_REPORT';
  var query = ['select',cols.join(','),'from',report,
               'where CampaignStatus = ENABLED',
               'and AdGroupStatus = ENABLED',
               'and Clicks > 0', // You can increase this to 
                                 // reduce the number of results
               'during',dateRange].join(' ');
  var reportIter = AdWordsApp.report(query).rows();
  var retVal = [];
  while(reportIter.hasNext()) {
    var row = reportIter.next();
    // If we don't have city level data, let's ignore it
    if(row['CityCriteriaId'] == 'Unspecified') { continue; }
    retVal.push(row);
  }
  return retVal;
}

// Helper function to return the report columns
function getReportColumns() {
  return ['Date','DayOfWeek',
          'CampaignId','CampaignName',
          'AdGroupId','AdGroupName',
          'CountryCriteriaId','RegionCriteriaId','MetroCriteriaId','CityCriteriaId',
          'Impressions','Clicks','Cost',
          'ConvertedClicks','ConversionValue',
          'AverageCpc'];
}

你可以用上面的函数抓取任意时间范围的数据。第一次你可能需要抓取过去30天或是更多的数据,之后,你可以让这段函数每天运行一次,抓取最新的数据。

另一方面,我打算用Weather Underground去抓紧天气历史数据。你可以免费注册并使用API,不过需要注意的是你很快会达到API使用次数的上限值。另外一个选择是使用 Open Weather Map API,不过个人感觉它比较难用。我只是想获取一些训练数据,因此目前,API限制对我而言并不重要。我在最终版的脚本中加入了一些变量和缓存机制以帮助我们处理处理可能遇到的API限制问题。

我们需要将AdWords报告中的地理位置“翻译”为Weather Underground可以理解的地理位置。为此,我们可以使用它们的 AutoComplete API。下面这段代码将会通过AdWords的地理业绩报告中的CityCriteriaId、RegionCriteriaId以及CountryCriteriaId找到Weather Underground中对应的URL。

/*********************************
 * Given a city, region, and country, return
 * the location information from WeatherUnderground.
 * Uses CACHEs to improve performance and reduce
 * calls to API.
 *********************************/
var CITY_LOOKUP_CACHE = {};
var COUNTRY_TO_CODE_MAP = getCountryCodesMap();
var WU_AUTOCOMPLETE_BASE_URL = 'http://autocomplete.wunderground.com/aq';
function getWeatherUndergroundLocation(city,region,country) {
  // Create a key for looking up data in our cache
  var cityCacheKey = [city,region,country].join('-');
  if(CITY_LOOKUP_CACHE[cityCacheKey]) {
    return CITY_LOOKUP_CACHE[cityCacheKey];
  }
  var urlParams = { 'cities': 1, 'h': 0 };
  if(country) {
    urlParams['c'] = COUNTRY_TO_CODE_MAP[country];
  }
  var urlsToCheck = [];
  // We check the more specific location first
  if(region && region != 'Unspecified') {
    urlParams['query'] = city+', '+region;
    urlsToCheck.push(WU_AUTOCOMPLETE_BASE_URL+'?'+ toQueryString(urlParams));
  }
  // But also try just the city
 urlParams['query'] = city;
  urlsToCheck.push(WU_AUTOCOMPLETE_BASE_URL+'?'+ toQueryString(urlParams));

  for(var i in urlsToCheck) {
    var urlToCheck = urlsToCheck[i];
    (ENABLE_LOGGING && Logger.log('Checking Url: '+urlToCheck));
    var resp = UrlFetchApp.fetch(urlToCheck,{muteHttpExceptions:true});
    if(resp.getResponseCode() == 200) {
      var jsonResults = JSON.parse(resp.getContentText());
      if(jsonResults.RESULTS.length > 0) {
        CITY_LOOKUP_CACHE[cityCacheKey] = jsonResults.RESULTS[0];
        return jsonResults.RESULTS[0];
      }
    }
    // Otherwise sleep a bit and try the 
    // other url.
    Utilities.sleep(500);
  }
  // If we can't find the city, just ignore it
  (ENABLE_LOGGING && Logger.log('Could not find data for: '+cityCacheKey));
  CITY_LOOKUP_CACHE[cityCacheKey] = false;
  return {};
}

// Converts {a:b,c:d} to a=b&c=d
// Taken from: http://goo.gl/5pG5oY
function toQueryString(hash) {
  var parts = [];
  for (var key in hash) {
    parts.push(key + "=" + encodeURIComponent(hash[key]));
  }
  return parts.join("&");
}

有一点需要注意的是,AdWords的地理业绩报告中出现的是国家的全名,而Weather Underground使用的是两位的ISO国家代码。下面这个小函数将根据Open Knowledge的数据信息帮助我们构建一份国家全名与ISO国家代码之间的映射关系。

/**********************************
 * This function returns a mapping of country codes
 * to two digit country codes.
 * { "United States" : "US", ... }
 **********************************/
function getCountryCodesMap() {
  var url = 'http://data.okfn.org/data/core/country-codes/r/country-codes.json';
  var resp = UrlFetchApp.fetch(url,{muteHttpExceptions:true});
  if(resp.getResponseCode() == 200) {
    var jsonData = JSON.parse(resp.getContentText());
    var retVal = {};
    for(var i in jsonData) {
      var country = jsonData[i];
      if(!country){ continue; }
      retVal[country.name] = country['ISO3166-1-Alpha-2'];
    }
    // Fixing some values. There may be more but
    // Weather Undergrounds' country mapping is 
    // pretty arbitrary. This page might help
    // http://goo.gl/J17Ve6 but it always accurate.
    retVal['South Korea'] = 'KR';
    retVal['Japan'] = 'JA';
    retVal['Isreal'] = 'IS';
    retVal['Spain'] = 'SP';
    retVal['United Kingdom'] = 'UK';
    retVal['Switzerland'] = 'SW';
    return retVal;
  } else {
    throw 'ERROR: Could not fetch country mapping. Response Code: '+
          resp.getResponseCode();
  }
}

这段代码通过缓存机制可以帮助我们提高城市名字和城市代码之间的匹配速度并减少API调用。一旦我们根据国家名字找到了这个国家的代码,就将其存储在CITY_LOOKUP_CACHE中,这样以后就不需要再通过调用API去查询了。

既然现在我们已经从Adwords获取了地理数据以及从Weather Underground获取了位置信息,我们就可以开始查找该地区的历史天气信息了。下面这个函数将帮助我们查询特定日期、特定地区的历史天气信息。同样,这里我们也使用了缓存机制来减少API的调用。

/************************************
 * Calls the Weather Underground history api
 * for a given location, date, and timezone
 * and returns the weather information. It 
 * utilizes a cache to conserve api calls
 ************************************/
var WU_API_KEY = 'YOUR WU API KEY HERE';
var WEATHER_LOOKUP_CACHE = {};
function getHistoricalTemp(wuUrl,date,timeZone) {
  if(!wuUrl) { return {}; }
  if(WU_TOTAL_CALLS_PER_DAY <= 0) {
    throw 'Out of WU API calls for today.';
  }
  var weatherCacheKey = [wuUrl,date,timeZone].join('-');
  if(WEATHER_LOOKUP_CACHE[weatherCacheKey]) {
    return WEATHER_LOOKUP_CACHE[weatherCacheKey];
  }
  var formattedDate = date.replace(/-/g,'');
  var url = ['http://api.wunderground.com/api/',
             WU_API_KEY,
             '/history_',
             formattedDate,
             wuUrl,'.json'].join('');
  (ENABLE_LOGGING && Logger.log('Checking Url: '+url));
  var resp = UrlFetchApp.fetch(url,{muteHttpExceptions:true});

  // This keeps you within the WU API guidelines
  WU_TOTAL_CALLS_PER_DAY--;
  Utilities.sleep(1000*(60/WU_CALLS_PER_MIN));

  if(resp.getResponseCode() == 200) {
    var jsonResults = JSON.parse(resp.getContentText());
    WEATHER_LOOKUP_CACHE[weatherCacheKey] = jsonResults.history.dailysummary[0];
    return jsonResults.history.dailysummary[0];
  }
  (ENABLE_LOGGING && Logger.log('Could not find historical weather for: '+weatherCacheKey));
  WEATHER_LOOKUP_CACHE[weatherCacheKey] = false;
  return {};
}

通过将这些代码片段组合在一起合成一段新的脚本,我们可以调用这个脚本每天定时将获取的训练数据存储在spreadsheet中,这样我们的模型脚本就可以访问这些数据了。这里是main函数以及其他一些工具函数帮助我们将之前的代码片段组合在一起。

// Set this to false to disable all logging
var ENABLE_LOGGING = true;
// The name of the spreadsheet you want to store your training
// data in. Should be unique.
var TRAINING_DATA_SPREADSHEET_NAME = 'Super Cool Training Data';
// The date range for looking up data. On the first run, you can 
// set this value to be longer. When scheduling for daily runs, this
// should be set for YESTERDAY
var DATE_RANGE = 'YESTERDAY';
// These values help you stay within the limits of
// the Weather Underground API. More details can be found
// in your Weather Underground account.
var WU_CALLS_PER_MIN = 10; // or 100 or 1000 for paid plans
var WU_TOTAL_CALLS_PER_DAY = 500; // or 5000, or 100,000 for paid plans

function main() {
  var sheet = getSheet(TRAINING_DATA_SPREADSHEET_NAME);
  // If the sheet is blank, let's add the headers
  if(sheet.getDataRange().getValues()[0][0] == '') {
    sheet.appendRow(getReportColumns().concat(['Mean Temp','Mean Windspeed','Conditions']));
  }
  var results = getGeoReport(DATE_RANGE);
  for(var key in results) {
    var row = results[key];
    var loc = getWeatherUndergroundLocation(row.CityCriteriaId,
                                            row.RegionCriteriaId,
                                            row.CountryCriteriaId);
    var historicalWeather = getHistoricalTemp(loc.l,results[key].Date,loc.tz);
    // See below. This pulls the info out of the weather results
    // and translates the average conditions.
    var translatedConditions = translateConditions(historicalWeather);
    sheet.appendRow(translateRowToArray(row).concat(translatedConditions));
    // Break before you run out of quota
    if(AdWordsApp.getExecutionInfo().getRemainingTime() < 10/*seconds*/) { break; }
  }
}

// Helper function to get or create a spreadsheet 
function getSheet(spreadsheetName) {
  var fileIter = DriveApp.getFilesByName(spreadsheetName);
  if(fileIter.hasNext()) {
    return SpreadsheetApp.openByUrl(fileIter.next().getUrl()).getActiveSheet();
  } else {
    return SpreadsheetApp.create(spreadsheetName).getActiveSheet();
  }
}

// Helper function to convert a report row to an array
function translateRowToArray(row) {
  var cols = getReportColumns();
  var ssRow = [];
  for(var i in cols) {
    ssRow.push(row[cols[i]]);
  }
  return ssRow;
}

/**********************************
 * Given a result from the Weather Underground
 * history API, it pulls out the average temp,
 * windspeed, and translates the conditons for 
 * rain, snow, etc. It returns an array of values.
 **********************************/
function translateConditions(historicalWeather) {
  var retVal = [];
  // in meantempi, the i is for Imperial. use c for Celcius.
  if(historicalWeather && historicalWeather['meantempi']) {
    retVal.push(historicalWeather['meantempi']);
    retVal.push(historicalWeather['meanwindspdi']);
    if(historicalWeather['rain'] == 1) {
      retVal.push('rain');
    } else if(historicalWeather['snow'] == 1) {
      retVal.push('snow');
    } else if(historicalWeather['hail'] == 1) {
      retVal.push('hail');
    } else if(historicalWeather['thunder'] == 1) {
      retVal.push('thunder');
    } else if(historicalWeather['tornado'] == 1) {
      retVal.push('tornado');
    } else if(historicalWeather['fog'] == 1) {
      retVal.push('fog');
    } else {
      retVal.push('clear');
    }
    return retVal;
  }
  return [];
}

现在,我们已经编写完成了用于获取训练数据的脚本。接着我们开始编写第二个脚本,即构建我们的学习模型的脚本。为此,我们需要先要在 Advanced APIs中打开Prediction API的开关,接着按照其指示进入 Google API控制台激活API。

上述步骤完成后,我们就可以开始构建模型了。下面这段代码的功能是从我们之前构建的spreadsheet中获取数据并构建模型。

有一点需要注意的是我们忽略了训练数据中的部分项目。这是因为当一个项目的值是唯一的时---例如日期---它对我们的预测并没有任何帮助。另外,我们还忽略了那些在某个层级上也是唯一的项目,例如Campaign(广告系列)名和Campaign(广告系列)ID。而且这些项目还对我们的模型的灵活性产生影响,因为有时我们希望将它们作为参数和我们查询一起传递给模型,所以我忽略了这些实际上并不会影响我们的平均cost-per-click (CPC)的项目。

我还排除了广告的显示次数、点击次数和点击成本等。这是因为当我向模型查询平均CPC的时候,我并不知道这些值。当然,你也可以在向模型查询的时候,传入一些假想值以观察它们对结果会产生什么影响。你可以在这里按照自己的想法做出改变,你也可以构建其他各种不同的模型来比较它们之间的预测结果(只需要换个名字即可)。

/***********************************
 * This function accepts a sheet full of training
 * data and creates a trained model for you to query.
 ***********************************/
 // Unique name for your model. Maybe add a date here
 // if you are doing iterations on your model.
 var MODEL_NAME = 'Weather Training Model';
 // This Id should be listed in your Developers Console
 // when you authorize the script
 var PROJECT_ID = 'PROJECT ID FROM DEVELOPER CONSOLE';
 // These are the names of your columns from the training
 // data to ignore. Change these to create variations of your
 // model for testing
 var COLS_TO_IGNORE = [
   'Date','CampaignId','CampaignName','AdGroupId','AdGroupName',
   'MetroCriteriaId','Impressions','Clicks','Cost'
 ];
 // This is the output column for your training data, or
 // what value the model is supposed to predict
 var OUTPUT_COLUMN = 'AverageCpc';

 function createTrainingModel(sheet) {
   var trainingInstances = [];
   // get the spreadsheet values
   var trainingData = sheet.getDataRange().getValues();
   var headers = trainingData.shift();
   for(var r in trainingData) {
     var inputs = [];
     var row = trainingData[r];
     for(var i in headers) {
       if(COLS_TO_IGNORE.indexOf(headers[i]) == -1 && headers[i] != OUTPUT_COLUMN) {
         inputs.push(row[i])
       }
     }
     var output = row[headers.indexOf(OUTPUT_COLUMN)];
     trainingInstances.push(createTrainingInstance(inputs,output));
   }

   var insert = Prediction.newInsert();
   insert.id = MODEL_NAME;
   insert.trainingInstances = trainingInstances;

   var insertReply = Prediction.Trainedmodels.insert(insert, PROJECT_ID);
   Logger.log('Trained model with data.');
 }

 // Helper function to create the training instance.
 function createTrainingInstance(inputs,output) {
   var trainingInstances = Prediction.newInsertTrainingInstances();
   trainingInstances.csvInstance = inputs;
   trainingInstances.output = output;
   return trainingInstances;
 }

构建模型的代码只需运行一次,之后就需要禁用它。因此,你可以在main函数中像下面这样做。

// The name of the spreadsheet containing your training data
var TRAINING_DATA_SPREADSHEET_NAME = 'Weather Model Training Data';
function main() {
  var sheet = getSheet(TRAINING_DATA_SPREADSHEET_NAME);
  createTrainingModel(sheet);
  //makePrediction();
}

// Helper function to get an existing sheet
// Throws an error if the sheet doesn't exist
function getSheet(spreadsheetName) {
  var fileIter = DriveApp.getFilesByName(spreadsheetName);
  if(fileIter.hasNext()) {
    return SpreadsheetApp.openByUrl(fileIter.next().getUrl()).getActiveSheet();
  }
  throw 'Sheet not found: '+spreadsheetName;
}

现在你已经创建出了自己的模型,那么当你的spreadsheet中持续地增加了新的数据时,你也需要持续地更新你的模型。你可以使用下面这段类似训练函数的代码每天将新的训练数据加入到你的模型中。

function updateTrainedModelData(sheet) {
  var updateData = sheet.getDataRange().getValues();
  var headers = updateData.shift();
  for(var r in updateData) {
    var inputs = [];
   var row = updateData[r];
    for(var i in headers) {
      if(COLS_TO_IGNORE.indexOf(headers[i]) == -1 && headers[i] != OUTPUT_COLUMN) {
        inputs.push(row[i])
      }
    }
    var output = row[headers.indexOf(OUTPUT_COLUMN)];
    var update = createUpdateInstance(inputs,output)
    var updateResponse = Prediction.Trainedmodels.update(update, PROJECT_ID, MODEL_NAME);
    Logger.log('Trained model updated with new data.');
  }
}

// Helper function to create the update instance.
function createUpdateInstance(inputs,output) {
  var updateInstance = Prediction.newUpdate();
  updateInstance.csvInstance = inputs;
  updateInstance.output = output;
  return updateInstance;
}

请确保不要错用以前的数据去更新你的模型。一旦数据被更新到了你的模型中,需要立即将它们移动至另外一个spreadsheet中或是删除掉。

现在,我们可以很简单地让模型进行预测了。下面的代码接受一个数组作为参数,数组中保存的是查询条件(即数值的数组,类似前面看到的训练数据中的特征变量),返回的结果是每行查询所对应的预测值。测试该模型的一种方法是抓取另外一组训练数据,将其中的输入结果字段删除掉后传递给模型,看看输出结果与实际结果是否吻合。

/***************************
 * Accepts a 2d array of query data and returns the
 * predicted output in an array.
 ***************************/
function makePrediction(data) {
  var retVal = [];
  for(var r in data) {
    var request = Prediction.newInput();
    request.input = Prediction.newInputInput();
    request.input.csvInstance = data[r];
    var predictionResult = Prediction.Trainedmodels.predict(
      request, PROJECT_ID, MODEL_NAME);
    Logger.log("Prediction for data: %s is %s",
               JSON.stringify(data[r]), predictionResult.outputValue);
    retVal.push(predictionResult.outputValue);
  }
  return retVal;
}

作为测试示例,我们使用上面的测试方法进行测试。现在我们的main函数是下面这样的

function main() {
  var sheet = getSheet(TRAINING_DATA_SPREADSHEET_NAME);
  //createTrainingModel(sheet);
  //updateTrainedModelData(sheet);
  var queries = [];
  // We are going to test it by querying with training data
  var testData = sheet.getDataRange().getValues();
  var headers = testData.shift();
  for(var r in testData) {
    var query = [];
    var row = testData[r];
    for(var i in headers) {
      if(COLS_TO_IGNORE.indexOf(headers[i]) == -1 && headers[i] != OUTPUT_COLUMN) {
        query.push(row[i])
      }
    }
    queries.push(query);
  }
  Logger.log(makePrediction(queries));
}

这样,编码就全部结束了。恭喜你完成了预测的第一步。接下来你需要通过调整和测试你的模型来让你的预测结果更加合理。如果预测结果有问题,可以查看训练数据,其中可能含有不需要的项目。

这确实很神奇!需要花费一个数学科学家团队几个月时间去解决的问题,现在只需要用AdWords Scripts和几行代码即可搞定。当然,还不止这些。你可以使用任何其他外部数据来构建你的模型。你不再需要定义“当大于X,则做Y”这样的规则,而是可以根据你自己的机器学习算法的输出结果来做决定。

当然,力量越大责任也越重!你需要很多时间和大量的数量才能让你的模型做出最合理的预测,所以现在就开始动手吧!