Forked from russorat/AdWords_Scripts_Ad_Testing.js
Last active
August 29, 2015 14:00
-
-
Save philbar/25136eafe9e0f4f38423 to your computer and use it in GitHub Desktop.
Replace Visitor Threshold with Chi-Squared test
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/********************************************* | |
* Automated Creative Testing With Statistical Significance | |
* Version 2.1 | |
* Changelog v2.1 - Replace Visitor Threshold with Chi-Squared test | |
* Changelog v2.0 | |
* - Fixed bug in setting the correct date | |
* - Script now uses a minimum visitors threshold | |
* per Ad instead of AdGroup | |
* - Added the ability to add the start date as a label to AdGroups | |
* - Added ability to check mobile and desktop ads separately | |
* Changelog v1.1.1 - Fixed bug with getDisplayUrl | |
* Changelog v1.1 | |
* - Added ability to only run on some campaigns | |
* - Fixed bug in info logging | |
* Russ Savage | |
* FreeAdWordsScripts.com | |
**********************************************/ | |
var EXTRA_LOGS = true; | |
var TO = ['[email protected]']; | |
var CONFIDENCE_LEVEL = 95; // 90%, 95%, or 99% are most common | |
//If you only want to run on some campaigns, apply a label to them | |
//and put the name of the label here. Leave blank to run on all campaigns. | |
var CAMPAIGN_LABEL = ''; | |
//These two metrics are the components that make up the metric you | |
//want to compare. For example, this measures CTR = Clicks/Impressions | |
//Other examples might be: | |
// Cost Per Conv = Cost/Conversions | |
// Conversion Rate = Conversions/Clicks | |
// Cost Per Click = Cost/Clicks | |
var VISITORS_METRIC = 'Impressions'; | |
var CONVERSIONS_METRIC = 'Clicks'; | |
//Setting this to true to enable the script to check mobile ads | |
//against other mobile ads only. Enabling this will start new tests | |
//in all your AdGroups so only enable this after you have completed | |
//a testing cycle. | |
var ENABLE_MOBILE_AD_TESTING = false; | |
//Set this on the first run which should be the approximate last time | |
//you started a new creative test. After the first run, this setting | |
//will be ignored. | |
var OVERRIDE_LAST_TOUCHED_DATE = 'Jan 1, 2014'; | |
var LOSER_LABEL = 'Loser '+CONFIDENCE_LEVEL+'% Confidence'; | |
var CHAMPION_LABEL = 'Current Champion'; | |
// Set this to true and the script will apply a label to | |
// each AdGroup to let you know the date the test started | |
// This helps you validate the results of the script. | |
var APPLY_TEST_START_DATE_LABELS = true; | |
//These come from the url when you are logged into AdWords | |
//Set these if you want your emails to link directly to the AdGroup | |
var __c = ''; | |
var __u = ''; | |
function main() { | |
createLabelIfNeeded(LOSER_LABEL,"#FF00FF"); //Set the colors of the labels here | |
createLabelIfNeeded(CHAMPION_LABEL,"#0000FF"); //Set the colors of the labels here | |
//Let's find all the AdGroups that have new tests starting | |
var currentAdMap = getCurrentAdsSnapshot(); | |
var previousAdMap = getPreviousAdsSnapshot(); | |
if(previousAdMap) { | |
currentAdMap = updateCurrentAdMap(currentAdMap,previousAdMap); | |
} | |
storeAdsSnapshot(currentAdMap); | |
previousAdMap = null; | |
//Now run through the AdGroups to find tests | |
var agSelector = AdWordsApp.adGroups() | |
.withCondition('CampaignStatus = ENABLED') | |
.withCondition('AdGroupStatus = ENABLED') | |
.withCondition('Status = ENABLED'); | |
if(CAMPAIGN_LABEL !== '') { | |
var campNames = getCampaignNames(); | |
agSelector = agSelector.withCondition("CampaignName IN ['"+campNames.join("','")+"']"); | |
} | |
var agIter = agSelector.get(); | |
var todayDate = getDateString(new Date(),'yyyyMMdd'); | |
var touchedAdGroups = []; | |
var finishedEarly = false; | |
while(agIter.hasNext()) { | |
var ag = agIter.next(); | |
var numLoops = (ENABLE_MOBILE_AD_TESTING) ? 2 : 1; | |
for(var loopNum = 0; loopNum < numLoops; loopNum++) { | |
var isMobile = (loopNum == 1); | |
var rowKey; | |
if(isMobile) { | |
info('Checking Mobile Ads in AdGroup: "'+ag.getName()+'"'); | |
rowKey = [ag.getCampaign().getId(),ag.getId(),'Mobile'].join('-'); | |
} else { | |
info('Checking Ads in AdGroup: "'+ag.getName()+'"'); | |
rowKey = [ag.getCampaign().getId(),ag.getId()].join('-'); | |
} | |
if(!currentAdMap[rowKey]) { //This shouldn't happen | |
warn('Could not find AdGroup: '+ag.getName()+' in current ad map.'); | |
continue; | |
} | |
if(APPLY_TEST_START_DATE_LABELS) { | |
var dateLabel; | |
if(isMobile) { | |
dateLabel = 'Mobile Tests Started: '+getDateString(currentAdMap[rowKey].lastTouched,'yyyy-MM-dd'); | |
} else { | |
dateLabel = 'Tests Started: '+getDateString(currentAdMap[rowKey].lastTouched,'yyyy-MM-dd'); | |
} | |
createLabelIfNeeded(dateLabel,"#8A2BE2"); | |
//remove old start date | |
var labelIter = ag.labels().withCondition("Name STARTS_WITH '"+dateLabel.split(':')[0]+"'") | |
.withCondition("Name != '"+dateLabel+"'").get(); | |
while(labelIter.hasNext()) { | |
var label = labelIter.next(); | |
ag.removeLabel(label.getName()); | |
if(!label.adGroups().get().hasNext()) { | |
//if there are no more entities with that label, delete it. | |
label.remove(); | |
} | |
} | |
applyLabel(ag,dateLabel); | |
} | |
//Here is the date range for the test metrics | |
var lastTouchedDate = getDateString(currentAdMap[rowKey].lastTouched,'yyyyMMdd'); | |
info('Last Touched Date: '+lastTouchedDate+' Todays Date: '+ todayDate); | |
if(lastTouchedDate === todayDate) { | |
//Special case where the AdGroup was updated today which means a new test has started. | |
//Remove the old labels, but keep the champion as the control for the next test | |
info('New test is starting in AdGroup: '+ag.getName()); | |
removeLoserLabelsFromAds(ag,isMobile); | |
continue; | |
} | |
//Is there a previous winner? if so we should use it as the control. | |
var controlAd = checkForPreviousWinner(ag,isMobile); | |
//Here we order by the Visitors metric and use that as a control if we don't have one | |
var adSelector = ag.ads().withCondition('Status = ENABLED').withCondition('AdType = TEXT_AD'); | |
if(!AdWordsApp.getExecutionInfo().isPreview()) { | |
adSelector = adSelector.withCondition("LabelNames CONTAINS_NONE ['"+[LOSER_LABEL,CHAMPION_LABEL].join("','")+"']"); | |
} | |
var adIter = adSelector.forDateRange(lastTouchedDate, todayDate) | |
.orderBy(VISITORS_METRIC+" DESC") | |
.get(); | |
if( (controlAd == null && adIter.totalNumEntities() < 2) || | |
(controlAd != null && adIter.totalNumEntities() < 1) ) | |
{ | |
info('AdGroup did not have enough eligible Ads. Had: '+adIter.totalNumEntities()+', Needed at least 2'); | |
continue; | |
} | |
if(!controlAd) { | |
info('No control set for AdGroup. Setting one.'); | |
while(adIter.hasNext()) { | |
var ad = adIter.next(); | |
if(shouldSkip(isMobile,ad)) { continue; } | |
controlAd = ad; | |
break; | |
} | |
if(!controlAd) { | |
continue; | |
} | |
applyLabel(controlAd,CHAMPION_LABEL); | |
} | |
while(adIter.hasNext()) { | |
var testAd = adIter.next(); | |
if(shouldSkip(isMobile,testAd)) { continue; } | |
//The Test object does all the heavy lifting for us. | |
var test = new Test(controlAd,testAd, | |
CONFIDENCE_LEVEL, | |
lastTouchedDate,todayDate, | |
VISITORS_METRIC,CONVERSIONS_METRIC); | |
info('Control - Visitors: '+test.getControlVisitors()+' Conversions: '+test.getControlConversions()); | |
info('Test - Visitors: '+test.getTestVisitors()+' Conversions: '+test.getTestConversions()); | |
info('P-Value: '+test.getPValue()); | |
// Chi-Squared Test (Less than 5% chance of being false positive) | |
// For simplicity, chi-squared equals 4, which results in a slightly higher than 95% confidence. | |
// Source: http://bit.ly/abhamster | |
if(test.getControlConversions() + test.getTestConversions() > Math.pow((test.getTestConversions() - test.getControlConversions()) / 2, 2)) | |
{ | |
info('False positive too likely. Skipping.'); | |
continue; | |
} | |
//Check for significance | |
if(test.isSignificant()) { | |
var loser = test.getLoser(); | |
removeLabel(loser,CHAMPION_LABEL); //Champion has been dethroned | |
applyLabel(loser,LOSER_LABEL); | |
//The winner is the new control. Could be the same as the old one. | |
controlAd = test.getWinner(); | |
applyLabel(controlAd,CHAMPION_LABEL); | |
//We store some metrics for a nice email later | |
if(!ag['touchCount']) { | |
ag['touchCount'] = 0; | |
touchedAdGroups.push(ag); | |
} | |
ag['touchCount']++; | |
} | |
} | |
//Let's bail if we run out of time so we can send the emails. | |
if((!AdWordsApp.getExecutionInfo().isPreview() && AdWordsApp.getExecutionInfo().getRemainingTime() < 60) || | |
( AdWordsApp.getExecutionInfo().isPreview() && AdWordsApp.getExecutionInfo().getRemainingTime() < 10) ) | |
{ | |
finishedEarly = true; | |
break; | |
} | |
} | |
} | |
if(touchedAdGroups.length > 0) { | |
sendMailForTouchedAdGroups(touchedAdGroups,finishedEarly); | |
} | |
beacon(); | |
} | |
// A helper function to return the list of campaign ids with a label for filtering | |
function getCampaignNames() { | |
var campNames = []; | |
var labelIter = AdWordsApp.labels().withCondition("Name = '"+CAMPAIGN_LABEL+"'").get(); | |
if(labelIter.hasNext()) { | |
var label = labelIter.next(); | |
var campIter = label.campaigns().get(); | |
while(campIter.hasNext()) { | |
campNames.push(campIter.next().getName()); | |
} | |
} | |
return campNames; | |
} | |
function applyLabel(entity,label) { | |
if(!AdWordsApp.getExecutionInfo().isPreview()) { | |
entity.applyLabel(label); | |
} else { | |
var adText = (entity.getEntityType() === 'Ad') ? [entity.getHeadline(),entity.getDescription1(), | |
entity.getDescription2(),entity.getDisplayUrl()].join(' ') | |
: entity.getName(); | |
Logger.log('PREVIEW: Would have applied label: '+label+' to Entity: '+ adText); | |
} | |
} | |
function removeLabel(ad,label) { | |
if(!AdWordsApp.getExecutionInfo().isPreview()) { | |
ad.removeLabel(label); | |
} else { | |
var adText = [ad.getHeadline(),ad.getDescription1(),ad.getDescription2(),ad.getDisplayUrl()].join(' '); | |
Logger.log('PREVIEW: Would have removed label: '+label+' from Ad: '+ adText); | |
} | |
} | |
// This function checks if the AdGroup has an Ad with a Champion Label | |
// If so, the new test should use that as the control. | |
function checkForPreviousWinner(ag,isMobile) { | |
var adSelector = ag.ads().withCondition('Status = ENABLED') | |
.withCondition('AdType = TEXT_AD'); | |
if(!AdWordsApp.getExecutionInfo().isPreview()) { | |
adSelector = adSelector.withCondition("LabelNames CONTAINS_ANY ['"+CHAMPION_LABEL+"']"); | |
} | |
var adIter = adSelector.get(); | |
while(adIter.hasNext()) { | |
var ad = adIter.next(); | |
if(shouldSkip(isMobile,ad)) { continue; } | |
info('Found a previous winner. Using it as the control.'); | |
return ad; | |
} | |
return null; | |
} | |
function shouldSkip(isMobile,ad) { | |
if(isMobile) { | |
if(!ad.isMobilePreferred()) { | |
return true; | |
} | |
} else { | |
if(ad.isMobilePreferred()) { | |
return true; | |
} | |
} | |
return false; | |
} | |
// This function sends the email to the people in the TO array. | |
// If the script finishes early, it adds a notice to the email. | |
function sendMailForTouchedAdGroups(ags,finishedEarly) { | |
var htmlBody = '<html><head></head><body>'; | |
if(finishedEarly) { | |
htmlBody += 'The script was not able to check all AdGroups. ' + | |
'It will check additional AdGroups on the next run.<br / >' ; | |
} | |
htmlBody += 'The following AdGroups have one or more creative tests that have finished.' ; | |
htmlBody += buildHtmlTable(ags); | |
htmlBody += '<p><small>Generated by <a href="http://www.freeadwordsscripts.com">FreeAdWordsScripts.com</a></small></p>' ; | |
htmlBody += '</body></html>'; | |
var options = { | |
htmlBody : htmlBody, | |
}; | |
var subject = ags.length + ' Creative Test(s) Completed - ' + | |
Utilities.formatDate(new Date(), AdWordsApp.currentAccount().getTimeZone(), 'yyyy-MM-dd'); | |
for(var i in TO) { | |
MailApp.sendEmail(TO[i], subject, ags.length+' AdGroup(s) have creative tests that have finished.', options); | |
} | |
} | |
// This function uses my HTMLTable object to build the styled html table for the email. | |
function buildHtmlTable(ags) { | |
var table = new HTMLTable(); | |
//CSS from: http://coding.smashingmagazine.com/2008/08/13/top-10-css-table-designs/ | |
//Inlined using: http://inlinestyler.torchboxapps.com/ | |
table.setTableStyle(['font-family: "Lucida Sans Unicode","Lucida Grande",Sans-Serif;', | |
'font-size: 12px;', | |
'background: #fff;', | |
'margin: 45px;', | |
'width: 480px;', | |
'border-collapse: collapse;', | |
'text-align: left'].join('')); | |
table.setHeaderStyle(['font-size: 14px;', | |
'font-weight: normal;', | |
'color: #039;', | |
'padding: 10px 8px;', | |
'border-bottom: 2px solid #6678b1'].join('')); | |
table.setCellStyle(['border-bottom: 1px solid #ccc;', | |
'padding: 4px 6px'].join('')); | |
table.addHeaderColumn('#'); | |
table.addHeaderColumn('Campaign Name'); | |
table.addHeaderColumn('AdGroup Name'); | |
table.addHeaderColumn('Tests Completed'); | |
for(var i in ags) { | |
table.newRow(); | |
table.addCell(table.getRowCount()); | |
var campName = ags[i].getCampaign().getName(); | |
var name = ags[i].getName(); | |
var touchCount = ags[i]['touchCount']; | |
var campLink, agLink; | |
if(__c !== '' && __u !== '') { // You should really set these. | |
campLink = getUrl(ags[i].getCampaign(),'Ad groups'); | |
agLink = getUrl(ags[i],'Ads'); | |
table.addCell(a(campLink,campName)); | |
table.addCell(a(agLink,name)); | |
} else { | |
table.addCell(campName); | |
table.addCell(name); | |
} | |
table.addCell(touchCount,'text-align: right'); | |
} | |
return table.toString(); | |
} | |
// Just a helper to build the html for a link. | |
function a(link,val) { | |
return '<a href="'+link+'">'+val+'</a>'; | |
} | |
// This function finds all the previous losers and removes their label. | |
// It is used when the script detects a change in the AdGroup and needs to | |
// start a new test. | |
function removeLoserLabelsFromAds(ag,isMobile) { | |
var adSelector = ag.ads().withCondition('Status = ENABLED'); | |
if(!AdWordsApp.getExecutionInfo().isPreview()) { | |
adSelector = adSelector.withCondition("LabelNames CONTAINS_ANY ['"+LOSER_LABEL+"']"); | |
} | |
var adIter = adSelector.get(); | |
while(adIter.hasNext()) { | |
var ad = adIter.next(); | |
if(shouldSkip(isMobile,ad)) { continue; } | |
removeLabel(ad,LOSER_LABEL); | |
} | |
} | |
// A helper function to create a new label if it doesn't exist in the account. | |
function createLabelIfNeeded(name,color) { | |
if(!AdWordsApp.labels().withCondition("Name = '"+name+"'").get().hasNext()) { | |
info('Creating label: "'+name+'"'); | |
AdWordsApp.createLabel(name,"",color); | |
} else { | |
info('Label: "'+name+'" already exists.'); | |
} | |
} | |
// This function compares the previous and current Ad maps and | |
// updates the current map with the date that the AdGroup was last touched. | |
// If OVERRIDE_LAST_TOUCHED_DATE is set and there is no previous data for the | |
// AdGroup, it uses that as the last touched date. | |
function updateCurrentAdMap(current,previous) { | |
info('Updating the current Ads map using historical snapshot.'); | |
for(var rowKey in current) { | |
var currentAds = current[rowKey].adIds; | |
var previousAds = (previous[rowKey]) ? previous[rowKey].adIds : []; | |
if(currentAds.join('-') === previousAds.join('-')) { | |
current[rowKey].lastTouched = previous[rowKey].lastTouched; | |
} | |
if(previousAds.length === 0 && OVERRIDE_LAST_TOUCHED_DATE !== '') { | |
current[rowKey].lastTouched = new Date(OVERRIDE_LAST_TOUCHED_DATE); | |
} | |
//if we make it here without going into the above if statements | |
//then the adgroup has changed and we should keep the new date | |
} | |
info('Finished updating the current Ad map.'); | |
return current; | |
} | |
// This stores the Ad map snapshot to a file so it can be used for the next run. | |
// The data is stored as a JSON string for easy reading later. | |
function storeAdsSnapshot(data) { | |
info('Storing the Ads snapshot to Google Drive.'); | |
var fileName = getSnapshotFilename(); | |
var file = DriveApp.getFilesByName(fileName).next(); | |
file.setContent(Utilities.jsonStringify(data)); | |
info('Finished.'); | |
} | |
// This reads the JSON formatted previous snapshot from a file on GDrive | |
// If the file doesn't exist, it creates a new one and returns an empty map. | |
function getPreviousAdsSnapshot() { | |
info('Loading the previous Ads snapshot from Google Drive.'); | |
var fileName = getSnapshotFilename(); | |
var fileIter = DriveApp.getFilesByName(fileName); | |
if(fileIter.hasNext()) { | |
return Utilities.jsonParse(fileIter.next().getBlob().getDataAsString()); | |
} else { | |
DriveApp.createFile(fileName, ''); | |
return {}; | |
} | |
} | |
// A helper function to build the filename for the snapshot. | |
function getSnapshotFilename() { | |
var accountId = AdWordsApp.currentAccount().getCustomerId(); | |
return (accountId + ' Ad Testing Script Snapshot.json'); | |
} | |
// This function pulls the Ad Performance Report which is the fastest | |
// way to build a snapshot of the current ads in the account. | |
// This only pulls in active text ads. | |
function getCurrentAdsSnapshot() { | |
info('Running Ad Performance Report to get current Ads snapshot.'); | |
var OPTIONS = { includeZeroImpressions : true }; | |
var cols = ['CampaignId','AdGroupId','Id','DevicePreference','Impressions']; | |
var report = 'AD_PERFORMANCE_REPORT'; | |
var query = ['select',cols.join(','),'from',report, | |
'where AdType = TEXT_AD', | |
'and AdNetworkType1 = SEARCH', | |
'and CampaignStatus = ACTIVE', | |
'and AdGroupStatus = ENABLED', | |
'and Status = ENABLED', | |
'during','TODAY'].join(' '); | |
var results = {}; // { campId-agId : row, ... } | |
var reportIter = AdWordsApp.report(query, OPTIONS).rows(); | |
while(reportIter.hasNext()) { | |
var row = reportIter.next(); | |
var rowKey = [row.CampaignId,row.AdGroupId].join('-'); | |
if(ENABLE_MOBILE_AD_TESTING && row.DevicePreference == 30001) { | |
rowKey += '-Mobile'; | |
} | |
if(!results[rowKey]) { | |
results[rowKey] = { adIds : [], lastTouched : new Date() }; | |
} | |
results[rowKey].adIds.push(row.Id); | |
} | |
for(var i in results) { | |
results[i].adIds.sort(); | |
} | |
info('Finished building the current Ad map.'); | |
return results; | |
} | |
//Helper function to format the date | |
function getDateString(date,format) { | |
return Utilities.formatDate(new Date(date),AdWordsApp.currentAccount().getTimeZone(),format); | |
} | |
// Function to build out the urls for deeplinking into the AdWords account. | |
// For this to work, you need to have __c and __u filled in. | |
// Taken from: http://www.freeadwordsscripts.com/2013/11/building-entity-deep-links-with-adwords.html | |
function getUrl(entity,tab) { | |
var customerId = __c; | |
var effectiveUserId = __u; | |
var decodedTab = getTab(tab); | |
var base = 'https://adwords.google.com/cm/CampaignMgmt?'; | |
var url = base+'__c='+customerId+'&__u='+effectiveUserId+'#'; | |
if(typeof entity['getEntityType'] === 'undefined') { | |
return url+'r.ONLINE.di&app=cm'; | |
} | |
var type = entity.getEntityType() | |
if(type === 'Campaign') { | |
return url+'c.'+entity.getId()+'.'+decodedTab+'&app=cm'; | |
} | |
if(type === 'AdGroup') { | |
return url+'a.'+entity.getId()+'_'+entity.getCampaign().getId()+'.'+decodedTab+'&app=cm'; | |
} | |
if(type === 'Keyword') { | |
return url+'a.'+entity.getAdGroup().getId()+'_'+entity.getCampaign().getId()+'.key&app=cm'; | |
} | |
if(type === 'Ad') { | |
return url+'a.'+entity.getAdGroup().getId()+'_'+entity.getCampaign().getId()+'.create&app=cm'; | |
} | |
return url+'r.ONLINE.di&app=cm'; | |
function getTab(tab) { | |
var mapping = { | |
'Ad groups':'ag','Settings:All settings':'st_sum', | |
'Settings:Locations':'st_loc','Settings:Ad schedule':'st_as', | |
'Settings:Devices':'st_p','Ads':'create', | |
'Keywords':'key','Audiences':'au','Ad extensions':'ae', | |
'Auto targets':'at','Dimensions' : 'di' | |
}; | |
if(mapping[tab]) { return mapping[tab]; } | |
return 'key'; //default to keyword tab | |
} | |
} | |
// Helper function to print info logs | |
function info(msg) { | |
if(EXTRA_LOGS) { | |
Logger.log('INFO: '+msg); | |
} | |
} | |
// Helper function to print more serious warnings | |
function warn(msg) { | |
Logger.log('WARNING: '+msg); | |
} | |
/******************************** | |
* Track Script Runs in Google Analytics | |
* Created By: Russ Savage | |
* FreeAdWordsScripts.com | |
********************************/ | |
function beacon() { | |
var TAG_ID = 'UA-40187672-2'; | |
var CAMPAIGN_SOURCE = 'adwords'; | |
var CAMPAIGN_MEDIUM = 'scripts'; | |
var CAMPAIGN_NAME = 'AdTestingScriptV2_0'; | |
var HOSTNAME = 'www.freeadwordsscripts.com'; | |
var PAGE = '/Ad_Testing_Script_v2_0'; | |
if(AdWordsApp.getExecutionInfo().isPreview()) { | |
PAGE += '/preview'; | |
} | |
var DOMAIN_LINK = 'http://'+HOSTNAME+PAGE; | |
//Pulled from: http://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid-in-javascript | |
var uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, | |
function(c) {var r = Math.random()*16|0,v=c=='x'?r:r&0x3|0x8;return v.toString(16);}); | |
var url = 'http://www.google-analytics.com/collect?'; | |
var payload = { | |
'v':1,'tid':TAG_ID,'cid':uuid, | |
't':'pageview','cs':CAMPAIGN_SOURCE,'cm':CAMPAIGN_MEDIUM,'cn':CAMPAIGN_NAME, | |
'dl':DOMAIN_LINK | |
}; | |
var qs = ''; | |
for(var key in payload) { | |
qs += key + '=' + encodeURIComponent(payload[key]) + '&'; | |
} | |
url += qs.substring(0,qs.length-1); | |
UrlFetchApp.fetch(url); | |
} | |
/********************************************* | |
* Test: A class for runnning A/B Tests for Ads | |
* Version 1.0 | |
* Based on VisualWebsiteOptimizer logic: http://goo.gl/jiImn | |
* Russ Savage | |
* FreeAdWordsScripts.com | |
**********************************************/ | |
// A description of the parmeters: | |
// control - the control Ad, test - the test Ad | |
// startDate, endDate - the start and end dates for the test | |
// visitorMetric, conversionMetric - the components of the metric to use for the test | |
function Test(control,test,desiredConf,startDate,endDate,visitorMetric,conversionMetric) { | |
this.desiredConfidence = desiredConf/100; | |
this.verMetric = visitorMetric; | |
this.conMetric = conversionMetric; | |
this.startDate = startDate; | |
this.endDate = endDate; | |
this.winner; | |
this.controlAd = control; | |
this.controlStats = (this.controlAd['stats']) ? this.controlAd['stats'] : this.controlAd.getStatsFor(this.startDate, this.endDate); | |
this.controlAd['stats'] = this.controlStats; | |
this.controlVisitors = this.controlStats['get'+this.verMetric](); | |
this.controlConversions = this.controlStats['get'+this.conMetric](); | |
this.controlCR = getConversionRate(this.controlVisitors,this.controlConversions); | |
this.testAd = test; | |
this.testStats = (this.testAd['stats']) ? this.testAd['stats'] : this.testAd.getStatsFor(this.startDate, this.endDate); | |
this.testAd['stats'] = this.testStats; | |
this.testVisitors = this.testStats['get'+this.verMetric](); | |
this.testConversions = this.testStats['get'+this.conMetric](); | |
this.testCR = getConversionRate(this.testVisitors,this.testConversions); | |
this.pValue; | |
this.getControlVisitors = function() { return this.controlVisitors; } | |
this.getControlConversions = function() { return this.controlConversions; } | |
this.getTestVisitors = function() { return this.testVisitors; } | |
this.getTestConversions = function() { return this.testConversions; } | |
// Returns the P-Value for the two Ads | |
this.getPValue = function() { | |
if(!this.pValue) { | |
this.pValue = calculatePValue(this); | |
} | |
return this.pValue; | |
}; | |
// Determines if the test has hit significance | |
this.isSignificant = function() { | |
var pValue = this.getPValue(); | |
if(pValue && pValue !== 'N/A' && (pValue >= this.desiredConfidence || pValue <= (1 - this.desiredConfidence))) { | |
return true; | |
} | |
return false; | |
} | |
// Returns the winning Ad | |
this.getWinner = function() { | |
if(this.decideWinner() === 'control') { | |
return this.controlAd; | |
} | |
if(this.decideWinner() === 'challenger') { | |
return this.testAd; | |
} | |
return null; | |
}; | |
// Returns the losing Ad | |
this.getLoser = function() { | |
if(this.decideWinner() === 'control') { | |
return this.testAd; | |
} | |
if(this.decideWinner() === 'challenger') { | |
return this.controlAd; | |
} | |
return null; | |
}; | |
// Determines if the control or the challenger won | |
this.decideWinner = function () { | |
if(this.winner) { | |
return this.winner; | |
} | |
if(this.isSignificant()) { | |
if(this.controlCR >= this.testCR) { | |
this.winner = 'control'; | |
} else { | |
this.winner = 'challenger'; | |
} | |
} else { | |
this.winner = 'no winner'; | |
} | |
return this.winner; | |
} | |
// This function returns the confidence level for the test | |
function calculatePValue(instance) { | |
var control = { | |
visitors: instance.controlVisitors, | |
conversions: instance.controlConversions, | |
cr: instance.controlCR | |
}; | |
var challenger = { | |
visitors: instance.testVisitors, | |
conversions: instance.testConversions, | |
cr: instance.testCR | |
}; | |
var z = getZScore(control,challenger); | |
if(z == -1) { return 'N/A'; } | |
var norm = normSDist(z); | |
return norm; | |
} | |
// A helper function to make rounding a little easier | |
function round(value) { | |
var decimals = Math.pow(10,5); | |
return Math.round(value*decimals)/decimals; | |
} | |
// Return the conversion rate for the test | |
function getConversionRate(visitors,conversions) { | |
if(visitors == 0) { | |
return -1; | |
} | |
return conversions/visitors; | |
} | |
function getStandardError(cr,visitors) { | |
if(visitors == 0) { | |
throw 'Visitors cannot be 0.'; | |
} | |
return Math.sqrt((cr*(1-cr)/visitors)); | |
} | |
function getZScore(c,t) { | |
try { | |
if(!c['se']) { c['se'] = getStandardError(c.cr,c.visitors); } | |
if(!t['se']) { t['se'] = getStandardError(t.cr,t.visitors); } | |
} catch(e) { | |
Logger.log(e); | |
return -1; | |
} | |
if((Math.sqrt(Math.pow(c.se,2)+Math.pow(t.se,2))) == 0) { | |
Logger.log('WARNING: Somehow the denominator in the Z-Score calulator was 0.'); | |
return -1; | |
} | |
return ((c.cr-t.cr)/Math.sqrt(Math.pow(c.se,2)+Math.pow(t.se,2))); | |
} | |
//From: http://www.codeproject.com/Articles/408214/Excel-Function-NORMSDIST-z | |
function normSDist(z) { | |
var sign = 1.0; | |
if (z < 0) { sign = -1; } | |
return round(0.5 * (1.0 + sign * erf(Math.abs(z)/Math.sqrt(2)))); | |
} | |
// From: http://picomath.org/javascript/erf.js.html | |
function erf(x) { | |
// constants | |
var a1 = 0.254829592; | |
var a2 = -0.284496736; | |
var a3 = 1.421413741; | |
var a4 = -1.453152027; | |
var a5 = 1.061405429; | |
var p = 0.3275911; | |
// Save the sign of x | |
var sign = 1; | |
if (x < 0) { | |
sign = -1; | |
} | |
x = Math.abs(x); | |
// A&S formula 7.1.26 | |
var t = 1.0/(1.0 + p*x); | |
var y = 1.0 - (((((a5*t + a4)*t) + a3)*t + a2)*t + a1)*t*Math.exp(-x*x); | |
return sign*y; | |
} | |
} | |
/********************************************* | |
* HTMLTable: A class for building HTML Tables | |
* Version 1.0 | |
* Russ Savage | |
* FreeAdWordsScripts.com | |
**********************************************/ | |
function HTMLTable() { | |
this.headers = []; | |
this.columnStyle = {}; | |
this.body = []; | |
this.currentRow = 0; | |
this.tableStyle; | |
this.headerStyle; | |
this.cellStyle; | |
this.addHeaderColumn = function(text) { | |
this.headers.push(text); | |
}; | |
this.addCell = function(text,style) { | |
if(!this.body[this.currentRow]) { | |
this.body[this.currentRow] = []; | |
} | |
this.body[this.currentRow].push({ val:text, style:(style) ? style : '' }); | |
}; | |
this.newRow = function() { | |
if(this.body != []) { | |
this.currentRow++; | |
} | |
}; | |
this.getRowCount = function() { | |
return this.currentRow; | |
}; | |
this.setTableStyle = function(css) { | |
this.tableStyle = css; | |
}; | |
this.setHeaderStyle = function(css) { | |
this.headerStyle = css; | |
}; | |
this.setCellStyle = function(css) { | |
this.cellStyle = css; | |
if(css[css.length-1] !== ';') { | |
this.cellStyle += ';'; | |
} | |
}; | |
this.toString = function() { | |
var retVal = '<table '; | |
if(this.tableStyle) { | |
retVal += 'style="'+this.tableStyle+'"'; | |
} | |
retVal += '>'+_getTableHead(this)+_getTableBody(this)+'</table>'; | |
return retVal; | |
}; | |
function _getTableHead(instance) { | |
var headerRow = ''; | |
for(var i in instance.headers) { | |
headerRow += _th(instance,instance.headers[i]); | |
} | |
return '<thead><tr>'+headerRow+'</tr></thead>'; | |
}; | |
function _getTableBody(instance) { | |
var retVal = '<tbody>'; | |
for(var r in instance.body) { | |
var rowHtml = '<tr>'; | |
for(var c in instance.body[r]) { | |
rowHtml += _td(instance,instance.body[r][c]); | |
} | |
rowHtml += '</tr>'; | |
retVal += rowHtml; | |
} | |
retVal += '</tbody>'; | |
return retVal; | |
}; | |
function _th(instance,val) { | |
var retVal = '<th scope="col" '; | |
if(instance.headerStyle) { | |
retVal += 'style="'+instance.headerStyle+'"'; | |
} | |
retVal += '>'+val+'</th>'; | |
return retVal; | |
}; | |
function _td(instance,cell) { | |
var retVal = '<td '; | |
if(instance.cellStyle || cell.style) { | |
retVal += 'style="'; | |
if(instance.cellStyle) { | |
retVal += instance.cellStyle; | |
} | |
if(cell.style) { | |
retVal += cell.style; | |
} | |
retVal += '"'; | |
} | |
retVal += '>'+cell.val+'</td>'; | |
return retVal; | |
}; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment