Confesión: desarrollamos software en R y Excel

Ejemplos del ecosistema basado en R que tenemos en el trabajo

Paula Cervilla, Leonardo Hansa

07/11/2024

Contexto

  • Empresa A quiere cuantificar el impacto de su publicidad en su negocio.
  • Empresa A contrata a Ebiquity.
  • Los consultores en Ebiquity usan estadística para medir ese impacto.

Más contexto

Los consultores de Ebiquity:

  • Saben Excel
  • Saben de estadística
  • Saben de publicidad

Pero no tienen por qué saber programar

Paula y yo los ayudamos cuando programar es imprescindible

Por ejemplo:

  • Datos en formatos complicados o datos no pequeños.
  • Automatización de tareas con herramientas internas.

Sí, usamos Excel

Nuestra herramienta de modelos econométricos se llama XSec. 1

Para especificar las variables de los modelos, usamos sintaxis de R

Variable Extra
log(cci)-log(min(cci,na.rm=TRUE))
gmpc_giveaway_alt
movav(adstock(dimret(.TV.,160,rescale=FALSE),0.3,normalize=TRUE),1)
adstock(m_drms_rd_spnd,0.3)
lag(bh_xmas, 1)
(OBS == "2015-12-14")
...

Y también VBA para llamar a R

Function RLocation() As String
Dim RLoc As String
RLoc = gwsRSpec.Range("location_RProg").Value & "\bin\x64\Rscript.exe"
If Not FileExists(RLoc) Then
    RLoc = gwsRSpec.Range("location_RProg").Value & "\bin\Rscript.exe"
End If
RLocation = RLoc
End Function

La combinación VBA y R hace que ajustar un modelo sea lo más parecido a pulsar un botón

Esa combinación se basa en que VBA llama a Rscript.exe

Sub run_variable_report()
Dim cmdline As String
Dim RLoc As String
Dim RScriptLoc As String
Dim workdir As String

reset_directory
get_r_path

If gwsRSpec.Range("regr_method") <> "DLM" Then
    RScriptLoc = """" & gwsRSpec.Range("location_RProg").Value & "\library\XSEXCELR\run_variable_report.R"""
    workdir = working_path()
    cmdline = """" & RLocation & """" & " " & RScriptLoc & " """ & workdir & """"
    Shell cmdline
'Clean up and properly format tables as R prepares

    write_all_spec (fmcg_second_run_flag)
    
    Application.EnableEvents = False
    ClearData
    NumberModelSpec
    Application.EnableEvents = True
Else
    MsgBox "Variable Report has not yet been implemented for DLM."
End If

End Sub

¿Qué os vamos a contar?

  • El ecosistema R que sustenta todo esto
  • El papel que juega Shiny
  • Qué hacemos mal (sabiéndolo)
  • Qué más nos gustaría hacer

ebverse, nuestro ecosistema de librerías

Excel llama a scripts gestionados por varias librerías internas, especializadas en distintas tareas

some_internal_packages <- c('ebcore',
                            'eble',
                            'ebility',
                            'ebdb',
                            'XSEXCELR',
                            'ebplot',
                            'ebjobs',
                            'debcomp',
                            'ebfmcg',
                            'ebverse',
                            'eb4cast',
                            'ebdlm',
                            'ebtestmatch')

XSEXCELR contiene las funciones principales de ajuste de modelos

XSEXCELR:::estimateSystem
function (spec, data) 
{
    if (!is.null(getOption("verbose_xsec"))) {
        writeLines(" Preparing model matrix")
    }
    m <- .initializeModel(spec = spec, data = data)
    if (spec$data$RegressionMethod != "DLM") {
        if (!is.null(getOption("verbose_xsec"))) {
            writeLines(" Solving coefficient boundaries")
        }
        .setConstraints(m)
        m <- XSEXCELR:::estimate_ar_terms(m = m, spec = spec, 
            data = data)
        set_summary(m = m)
        m <- auto_tstat(m = m, spec = spec, data = data)
    }
    return(m)
}
<bytecode: 0x000001d029eec530>
<environment: namespace:XSEXCELR>

Algunas de las transformaciones de nuestros modelos son muy típicas en el sector

eble tiene implementadas estas transformaciones

eble::adstock
function (x, p, normalize = TRUE, reverse = FALSE) 
{
    if (length(p) > 1) {
        stop("Input p to adstock must have a length of 1.")
    }
    if (p < 0 | p >= 1) {
        stop("Input p to adstock must be in [0, 1)")
    }
    if (class(x) == "factor") {
        warning("factor supplied to adstock function")
        x = as.character(x)
    }
    x[is.na(x)] = 0
    if (reverse == FALSE) {
        if (normalize) {
            x = x * (1 - p)
        }
        x = stats::filter(x, p, method = "recursive")
        return(as.numeric(x))
    }
    else {
        x = rev(adstock(rev(x), p, normalize))
        return(as.numeric(x))
    }
}
<bytecode: 0x000001d00f908640>
<environment: namespace:eble>

Lo de que desarrollamos software en Excel es mentira

Realmente lo hacemos en Shiny

¿Has usado Shiny alguna vez?

 

Un Shiny sirve para explorar y diagnosticar residuos

Otro Shiny sirve para explorar los datos de inversión en medios publicitarios

Otro lanza predicciones

¿Sabías que puedes hacer Shinys como si fueran librerías?

No reinventamos la rueda (cuando nos acordamos)

Usamos librerías que ya existen y hacen cosas con las que no queremos complicarnos, como GA

...
#' @import GA
#' @import foreach
#'
#' @return A `ga-class` object
#' @export
ga_xsec <- function(type = c("binary", "real-valued", "permutation"),
               fitness, ...,
               lower, upper, nBits,
               population = gaControl(type)$population,
               selection = gaControl(type)$selection,
               crossover = gaControl(type)$crossover,
               mutation = gaControl(type)$mutation,
               popSize = 50,
               pcrossover = 0.8,
               pmutation = 0.1,
               elitism = base::max(1, round(popSize*0.05)),
               updatePop = FALSE,
               postFitness = NULL,
               maxiter = 100,
               run = maxiter,
               maxFitness = Inf,
               names = NULL,
               suggestions = NULL,
               optim = FALSE,
               optimArgs = list(method = "SANN",
                                poptim = 0.15,
                                pressel = 0.7,
                                control = list(fnscale = -1, maxit = 100)),
               keepBest = FALSE,
               parallel = FALSE,
               monitor = if(interactive()) gaMonitor else FALSE,
               seed = NULL)
  call <- match.call()
  ...

Terminar antes es bueno a veces. Rcpp te ayuda

//' Apply a dim ret transformation to a variable.
//'
//' @param x A numeric vector, the variable to be transformed.
//' @param type A string, the type of transformation. `"adbug", "power"`.
//' @param a The diminishing return parameter.
//' @param e The exponent of the transformation.
//' @export
// [[Rcpp::export]]
NumericVector transform_variable(NumericVector x,
                                  Rcpp::String type,
                                  double a,
                                  double e)
{

  if (type == "adbug") {
    return pow(x, e) / (pow(x, e)+a);
  } else if (type == "power") {
    return pow(x,e);

  } else if (type == "log2") {
    for (int i = 0; i < x.size(); i++) {
      // If the element is zero or less than zero, set it to one
      if (x[i] <= 0) {
        x[i] = 1;
      }
    }
    return log(x);
  } else if (type == "log") {

    // zero and negative values will be replaced by a log average.
    // The logic is the same as the one in ebimodel. The code is from ChatGPT
    double sumLog = 0;
    int countPos = 0;
    for (int i = 0; i < x.size(); i++) {
      if (x[i] > 0) {
        sumLog += x[i];
        countPos++;
      }
    }

    double avgLog = countPos > 0 ? log(sumLog / countPos) : 0; // Calculate the average of the logarithms of positive numbers

    NumericVector transformed(x.size());
    for (int i = 0; i < x.size(); i++) {
      if (x[i] > 0) {
        transformed[i] = log(x[i]);
      } else {
        transformed[i] = avgLog;
      }
    }
    return transformed;
  } else if ((type == "pnegativo") | (type == "pneg")) {

    int nTimeSteps;
    nTimeSteps = x.size();
    NumericVector out(nTimeSteps);
    // Loop through each element in adst
    for (int i = 0; i < nTimeSteps; i++) {
      // Check if the element is non-negative
      if (x[i] >= 0) {
        // Raise the element to the given exponent
        out[i] = pow(x[i], e);
      }
      else {
        // Raise the absolute value of the element to the given exponent
        // and negate it
        out[i] = -1 * pow(-1 * x[i], e);
      }
    }
    return out;
  } else if ((type == "anegativo") | (type == "aneg")) {

    int nTimeSteps;
    nTimeSteps = x.size();
    NumericVector out(nTimeSteps);

    for (int i=0; i<nTimeSteps; i++){
      if (x[i] > 0)
        out[i] = pow(x[i], e) / (pow(x[i], e)+a);
      else if (x[i] < 0){
        out[i] = pow(-1.0 * x[i], e) / (pow(-1.0 * x[i], e)+a);
        out[i] = -1.0 * out[i];
      } else {
        out[i] = x[i];
      }
    }
    return out;
  } else {
    return x;
  }

}

scales nos simplifica mucho los gráficos

area_gg <- area_gg +
scale_y_continuous(labels = eb_labels, breaks = scales::breaks_pretty(n = break_y)) +
facet_wrap(~FacetBy, scales = "fixed") +
theme(strip.background = element_rect(fill = eb_col("ebblue"))) +
theme(strip.text = element_text(colour = "white", size = axis_size_area))
          

bslib hace que tus Shinys no sean tan feos como son por defecto

Cosas que (sabemos que) hacemos mal

  • testthat
  • Shiny Modules
  • importFrom en lugar de import

Podríamos tener más test en los paquetes

Tenemos test, pero solo cubren alrededor de un 25% de los paquetes (XSEXCELR)

test_that("the same columns are dropped if sparse or dense implementation", {
  set.seed(1)
  # =======
  # Aquí hay mucho código que no se muestra....
  # =======
  
  model1 <- XSEXCELR::extimate(spec = spec1, model = mod, xsec = xsec, var_report = FALSE, log = FALSE, data = list(data))
  model2 <- XSEXCELR::extimate(spec = spec2, model = mod, xsec = xsec, var_report = FALSE, log = FALSE, data = list(data))

  expect_equal(model1$COEF$COEF, model2$COEF$COEF)
})

Las apps de Shiny podrían estar hechas con módulos

output$DateRange_Area <- DateRangeUI(
  id = "area",
  data = dataFlt()
)

output$GroupBy_Area <- renderUI({
  GroupByUI(id = "area",
            data = dataFlt())
})

output$GroupByLevels_Area <- renderUI({
  GroupByLevelsUI(id = "area")
})

observe({
  req(input$groupby_area)
  data <- dataFlt()
  groupby_level <- unique(data[[input$groupby_area]])

  updatePickerInput(session,
    inputId = "groupby_level_area",
    label = "Filter Group By",
    choices = groupby_level,
    selected = groupby_level
  )
})
output$DateRange_Line <- DateRangeUI(
  id = "line",
  data = dataFlt()
)

output$GroupBy_Line <- renderUI({
  GroupByUI(id = "line",
            data = dataFlt())
})

output$GroupByLevels_Line <- renderUI({
  GroupByLevelsUI(id = "line")
})

observe({
  req(input$groupby_line)
  data <- dataFlt()
  groupby_level <- unique(data[[input$groupby_line]])

  updatePickerInput(session,
    inputId = "groupby_level_line",
    label = "Filter Group By",
    choices = groupby_level,
    selected = groupby_level
  )
})

No deberíamos importar librerías enteras, sino solo las funciones que necesitamos

Para que no pasen cosas como esta y se nos llene la consola de warnings:

Una cosa que no usamos pero nos gustaría mucho

Quarto

A disfrutar de Sevilla