【譯(英)】使用AdWords Scripts和Google Prediction API進行機器學習

楊文軒發表於2016-04-12

說明:原文連結: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”這樣的規則,而是可以根據你自己的機器學習演算法的輸出結果來做決定。

當然,力量越大責任也越重!你需要很多時間和大量的數量才能讓你的模型做出最合理的預測,所以現在就開始動手吧!

相關文章