Saturday, December 7, 2013

Automated Creative Testing With Statistical Significance

In the spirit of the Holidays, I've got a little gift for everyone. This script will monitor your AdGroups for creative tests that have hit statistical significance and notify you with an email so that you can take action. The script keeps track of changes to any AdGroups so that it always knows when a new test has started without you having to keep track. It applies labels to your Ads and then notifies you via email when the tests have completed.

This script is based on my previous Ad Testing Script but has some additional features. Namely, instead of pausing stats based purely on a metric, this script calculates the statistical significance of the test before making a decision. The calculations are based on a blog post and spreadsheet from VisualWebsiteOptimizer that I modified to work with AdWords Ads.

I also incorporated the ability to link directly to the Campaign or AdGroup from the notification email which I posted about before. All you need to do is fill in the __c and __u parameters to enable that feature.

And finally, since this is probably the largest and most complicated script I've shared here, I used the logic from my post about Google Analytics reporting to fire a beacon whenever the script is ran. You are more than welcome to disable it (remove beacon(); from the second to the last line of the main function), but I would love to get an idea of just how many people are using this script.

Since this script is pretty big, and the formatting here can be a little iffy sometimes, you can also download it from GitHub.

Thanks as always for reading and have a fun and safe holiday.


* Automated Creative Testing With Statistical Significance
* Version 2.1
* Changelog v2.1
* 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
var EXTRA_LOGS = true;
var TO = [''];
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.
//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';
//This is the number of impressions the Ad needs to have in order
//to start measuring the results of a test.

//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.

//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 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.
//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);
  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 =;

    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.');
        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');

        //remove old start date
        var labelIter = ag.labels().withCondition("Name STARTS_WITH '"+dateLabel.split(':')[0]+"'")
                                   .withCondition("Name != '"+dateLabel+"'").get();
        while(labelIter.hasNext()) {
          var label =;
          if(!label.adGroups().get().hasNext()) {
            //if there are no more entities with that label, delete it.
      //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());
      //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 ='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")
      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'); 
      if(!controlAd) {
        info('No control set for AdGroup. Setting one.');
        while(adIter.hasNext()) {
          var ad =;
          if(shouldSkip(isMobile,ad)) { continue; }
          controlAd = ad;
        if(!controlAd) {
      while(adIter.hasNext()) {
        var testAd =;
        if(shouldSkip(isMobile,testAd)) { continue; }
        //The Test object does all the heavy lifting for us.
        var test = new Test(controlAd,testAd,
        info('Control - Visitors: '+test.getControlVisitors()+' Conversions: '+test.getControlConversions());
        info('Test    - Visitors: '+test.getTestVisitors()+' Conversions: '+test.getTestConversions());
        info('P-Value: '+test.getPValue());
        if(test.getControlVisitors() < VISITORS_THRESHOLD ||
           test.getTestVisitors() < VISITORS_THRESHOLD)
          info('Not enough visitors in the control or test ad.  Skipping.');
        //Check for significance
        if(test.isSignificant()) {
          var loser = test.getLoser();
          removeLabel(loser,CHAMPION_LABEL); //Champion has been dethroned
          //The winner is the new control. Could be the same as the old one.
          controlAd = test.getWinner();
          //We store some metrics for a nice email later
          if(!ag['touchCount']) {
            ag['touchCount'] = 0;
      //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;
  if(touchedAdGroups.length > 0) {
// 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 =;
    var campIter = label.campaigns().get();
    while(campIter.hasNext()) {
  return campNames;
function applyLabel(entity,label) {
  if(!AdWordsApp.getExecutionInfo().isPreview()) {
  } 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()) {
  } 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 ='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 =;
    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=""></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:
  //Inlined using:
  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('Campaign Name');
  table.addHeaderColumn('AdGroup Name');
  table.addHeaderColumn('Tests Completed');
  for(var i in ags) {
    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');
    } else {
    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 ='Status = ENABLED');
  if(!AdWordsApp.getExecutionInfo().isPreview()) {
    adSelector = adSelector.withCondition("LabelNames CONTAINS_ANY ['"+LOSER_LABEL+"']");
  var adIter = adSelector.get();
  while(adIter.hasNext()) {
    var ad =;
    if(shouldSkip(isMobile,ad)) { continue; }
// 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+'"');
  } 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();
// 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(;
  } 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 = ENABLED',
               'and AdGroupStatus = ENABLED',
               'and Status = ENABLED',
               'during','TODAY'].join(' ');
  var results = {}; // { campId-agId : row, ... }
  var reportIter =, OPTIONS).rows();
  while(reportIter.hasNext()) {
    var row =;
    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() };
  for(var i in results) {
  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:
function getUrl(entity,tab) {
  var customerId = __c;
  var effectiveUserId = __u;
  var decodedTab = getTab(tab);  
  var base = '';
  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',
      '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
function beacon() {
  var TAG_ID = 'UA-40187672-2';
  var CAMPAIGN_SOURCE = 'adwords';
  var CAMPAIGN_MEDIUM = 'scripts';
  var CAMPAIGN_NAME = 'AdTestingScriptV2_1';
  var HOSTNAME = '';
  var PAGE = '/Ad_Testing_Script_v2_1';
  if(AdWordsApp.getExecutionInfo().isPreview()) {
    PAGE += '/preview';
  var DOMAIN_LINK = 'http://'+HOSTNAME+PAGE;
  //Pulled from:
  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 = '';
  var payload = {
  var qs = '';
  for(var key in payload) {
    qs += key + '=' + encodeURIComponent(payload[key]) + '&';
  url += qs.substring(0,qs.length-1);
* Test: A class for runnning A/B Tests for Ads
* Version 1.0
* Based on VisualWebsiteOptimizer logic:
* Russ Savage
// A description of the parameters:
// 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.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.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.visitors); }
      if(!t['se']) { t['se'] = getStandardError(,t.visitors); }
    } catch(e) {
      return -1;
    if((Math.sqrt(Math.pow(,2)+Math.pow(,2))) == 0) { 
      Logger.log('WARNING: Somehow the denominator in the Z-Score calulator was 0.');
      return -1;
    return ((,2)+Math.pow(,2)));
  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:
  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
function HTMLTable() {
  this.headers = [];
  this.columnStyle = {};
  this.body = [];
  this.currentRow = 0;
  this.addHeaderColumn = function(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.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 || {
      retVal += 'style="';
      if(instance.cellStyle) {
        retVal += instance.cellStyle;
      if( {
        retVal +=;
      retVal += '"';
    retVal += '>'+cell.val+'</td>';
    return retVal;


  1. Awesome script, but a few caveats :
    1°) To extend compatibility to new ad formats, we need to replace both occurences of
    AdType = TEXT_AD

    2°) If you run a test of the script (of if you run it several times), it will put the labels up and any next iteration of the script will use the current day as a start date because the ad map was modified by the labels... That's very annoying and I haven't found a workaraound yet. Do you know of one ?

    3°) For a while now ads have no longer been evenly rotating. The algo chooses the best ad whatever your settings are...

    So maybe the script should have a different strategy : if there's a significant statistical difference or Google clearly favored one ad the that ad should be championned. Otherwise not.
    What do you think ?

  2. Does this still work? The Ads Snapshot it saves to my Google Drive is blank, and it starts returning the error "WARNING: Could not find AdGroup: [my adgroup name] in current ad map.", repeats for each Ad Group, then does nothing. Any ideas? I've tried changing "TEXT_AD" to "EXPANDED_TEXT_AD" as per the suggestion above, but still no dice.

  3. Cartier watches boast a large variety of design and functionality, replica cartier ukyet maintain the utmost quality and sophistication in every series. Crafted from the finest steel, replica calibre de cartier carbon diver watchesrose gold, yellow gold, and white gold, and utlizing the highest grade of diamonds worthy of the Cartier name, Cartier has dominated the industry of elegant watches. Mens and ladies watches alike are assured attention to detail internally and externally, and the namesake itself is a milestone in the world for which the standard of reputation alone is reminiscent of the product.

  4. Buy a discounted replica watchesIWC watch from this Time and get free shipping. replica iwc watchesLarge selection in stock. IWC watches, is an iconic brand. The IWC Portuguese, a collection that includes the IWC Portuguese automatic and IWC Yacht Club chronograph is legendary. IWC Schaffhausen is also known for the IWC Aquatimer, Top Gun, Big Pilot watch, Mark XVII, Pilot's Watch, Spitfire chronograph and their Big Pilot Watch, the St-Exupery.

  5. ARe you looking for a decent essay writing service with a proved reputation? I can suggest you this one

  6. Our online assignment help is the most trusted among students from all universities across Australia. At MyAssignmentHelp, we suggest you relax more and concentrate on other important tasks and trust us to avail the best online assignments help.

  7. This so super post Creative Testing With Statistical Significance.Such a very useful article. Very interesting to read this article. I would like to thank you for the efforts you had made for writing this awesome article.The Best Cleaning Services in Dubai provides good service of cleaning.

  8. Thank you for your informative article, if you are looking for House Cleaning Services or Office Cleaning Services then please visit our cleaning services.

  9. You may stumble, may fail, maybe discouraged, but never give up. Please share great things with me run 3

  10. PayPal needs no introduction as a lot of people across the globe uses it for carrying out day-to-day transactions. It not only reduces the hurdle of paying in cash but also gives the users an opportunity to make instant payments on the go. Yes, you guessed that right! PayPal is a peer-to-peer money transferring platform as well as you can do online shopping with it.

    For more information visit site :- Paypal Login

  11. This so super post Creative Testing With Statistical Significance.Such a very useful article. Very interesting to read this article. I would like to thank you for the efforts you had made for writing this awesome article.Thank you for sharing excellent information. Your website is so cool. I am impressed by the details that you have on this website. It reveals how nicely you understand this subject.

    For more information visit site :-

    Cash App Login


  12. I am always searching online for articles that can help me. There is obviously a lot to know about this. I think you made some good points in Features also. Keep working and share more posts, you are doing great job.It is brilliant blog. I like the way you express information to us. It is very useful for me.Dubai Social Media Marketing

  13. My TV is an Amazon program that offers a simple interface to the My TV service through Amazon's own websites and software programs. As previously noted, the program uses a simple web interface which enables the user to watch various television stations in the United States and Canada.

    For more information visit site :-

  14. You can transfer money from your Venmo account to your bank account free. But if you want that money in a hurry, you’ll have to pay for it. You can do an instant transfer from Venmo to your linked debit card in a matter of minutes if you’re willing to pay 1% of the total transfer. However, you can still do a free transfer if you can wait a day or two for the money to go through.

    For more information visit site :- Xoom Login

    Amazon mytv

    Venmo Login

  15. I love your creativity. Are you also searching for nursing writing services? we are the best solution for you. We are best known for delivering nursing writing services to students without having to break the bank.

  16. Your blogs are great. Are you also searching for Help with my Math assignment? we are the best solution for you. We are best known for delivering quality essay writing services to students without having to break the bank

  17. once we hebben duidelijk het merk en het model moeten we bepaalde aspecten rekening te houden:Staat van instandhouding van het horloge. U moet zo waarheidsgetrouw, objectief en concreet mogelijk zijn in termen repliche rolex orologi van de conditie, gezien het feit dat bij het omgaan met een tweedehands luxe horloge, is het van vitaal belang om te verwijzen naar elk type van visuele schade die het horloge kan hebben, zoals bulten, krassen of andere schade die het horloge als geheel beïnvloedt. Op onze website hebben najlepszy rolex replika zegarka we onze eigen conditieschaal, u kunt hem hier bekijken. Replica HublotLaad beweging. Een fundamenteel aspect,Top Qualität Replik Uhren want we kunnen ze kwarts, manueel of automatisch vinden. Het Air Max 1 pas cher is niet altijd een doorslaggevende factor, hoewel kwarts, wat machines betreft, bijna altijd minder waard zal zijn dan de andere twee.