*! bbandits, version 2, 24.01.2026
*! Authors: Jan Kemper, Davud Rostam-Afschar

*** Import python functions
* Import python functions used for the bbandits package.
* All function are in the file bbandits_functions.py which has to be in the ado folder (likely under subfolder "py")

python:


from bbandits_functions import *

keys = ['Beta_OLS', 'Beta_BOLS_aggregated', 'Z-value', 'P-value', 'CI_lower_bound_95', 'CI_upper_bound_95', 'Treatment_arm_n', 'Reference_arm_n', 'Standard_error_BOLS', 'Arm_indicator']

keys_stats =  ['optimal_mean', 'optimal_reward', 'actual_reward_mean' , 'actual_reward' , 'uniform_reward_mean', 'uniform_reward']

end


program bbandits, eclass
	version 17
	syntax varlist [if] [in] [, Reference_arm(int 0) Test_value(real 0.0) Plot_thompson STacked histoptions(string asis) twoptions_thompson(string asis) twoptions_ols(string asis) twoptions_bols(string asis) twoptions_sharebybatch(string asis) twoptions_stackedsharebybatch(string asis) twoptions_cumsharesbyybatch(string asis) NO_plot]

*** Parse 3rd input variable
*di "`3'"
* problem: Data.get requires variable name as string but string stata parser
* returns "var," if "," is placed without space in the syntax "syntax varlist," instead of "syntax varlist ,"
* solution: replace "," with empty space

local var3 = subinstr("`3'", ",", "", .)

* Capture some input errors
local nvars : word count `varlist'
if `nvars' < 3 {
    di as error "bbandits requires at least outcome, treatment, and batch variables."
    di as error "Usage:  bbandits outcome treatment batch"
    exit 198
}

*** Stop the program if there are any missing values
qui count if missing(`1') | missing(`2') | missing(`var3')
if r(N) > 0 {
    display as error "ERROR: Missing values are not allowed in `1', `2', or `var3'."
    exit 198
}

** check if any arm is not played in any batch and warn the user
qui tabulate `2' `var3', matcell(freq)

local zero_found 0
forvalues i = 1/`=rowsof(freq)' {
    forvalues j = 1/`=colsof(freq)' {
        if (freq[`i',`j'] == 0) local zero_found 1
    }
}

if (`zero_found' == 1) {
    di as error "WARNING: One or more batches contain arms that were not played. Such batches are excluded from the corresponding BOLS calculations (see warnings below). To avoid this, ensure that every arm is played in every batch, for example by increasing the clipping rate or epsilon for epsilon-greedy."
}

matrix drop freq
* drop existing alpha_list and beta_list matrices
capture matrix drop alpha_list beta_list

*******************************************
*** Inference
*******************************************

********* Parsing & pre-processing *******

	capture drop label_chosen_arm
	capture drop chosen_arm_py
	* create a tempvar for chosen_arm
	tempvar chosen_arm_py
	capture confirm numeric variable `2'
if !_rc {
    * Numeric input → preserve numeric ordering
    egen `chosen_arm_py' = group(`2'), label
	* label variable for exports
	qui tostring `2', gen(label_chosen_arm) force
}
else {
    * String input → group by string labels
    egen `chosen_arm_py' = group(`2'), label
	capture gen label_chosen_arm = `2'

}
	qui replace `chosen_arm_py' = `chosen_arm_py' - 1

	capture confirm variable chosen_arm
	if !_rc {
		// The variable exists, do something
		drop chosen_arm // Not an elegant solution to drop the existing chosen_arm, better to work with tempvars
		gen chosen_arm = `chosen_arm_py'
	}
	else {
		// The variable does not exist, do something else or nothing
		gen chosen_arm = `chosen_arm_py'
	}

	*************** Get Data into python *******************
	* run inference command in python
	python: reward, chosen_arm, batch  = Data.get(var="`1'"), Data.get(var="chosen_arm"), Data.get(var="`var3'")
	python: data = pd.DataFrame({"reward": reward, "chosen_arm": chosen_arm, "batch": batch})
	python: results, ereturn_results = bols_inference_k(data, chosen_arm = "chosen_arm", reward = "reward", batch = "batch", reference_arm = `reference_arm', test_value = `test_value')
	*python: print(ereturn_results)
	* save results to matrix
	python: values = [results.get(key) for key in keys]
	python: mat_res = np.array(values).T

	python: batch_beta = ereturn_results["batch_beta"]
	python: weights_bols = ereturn_results["weights_bols"]

	* save beta coefficients for each batch
	python: Matrix.store("adaptive_inference", mat_res)
	python: Matrix.setColNames("adaptive_inference", keys)

	* beta bols estimates
	python: Matrix.store("batch_beta", batch_beta)
	python: Matrix.store("weights_bols", weights_bols)

	*matrix list weights_bols
	ereturn matrix res adaptive_inference
	ereturn matrix batch_ols_coefficients batch_beta
	ereturn matrix batched_ols_weights weights_bols

	* Additional stats from python to stata matrix
	python: values = [ereturn_results.get(key) for key in keys_stats]
	python: mat_add_stats = np.array(values).T
	python: Matrix.store("mat_add_stats", mat_add_stats)
	python: Matrix.setRowNames("mat_add_stats", keys_stats)


	tempname res batch_ols_coefficients batched_ols_weights
	mat `res' = e(res)
	mat `batch_ols_coefficients' = e(batch_ols_coefficients)
	mat `batched_ols_weights' = e(batched_ols_weights)
	* store estimation results
	* Display the results in a nice fashion
	*** Plot thompson development if specified
	if "`plot_thompson'" == "" & "`stacked'" != ""{
	di in red "please use stacked with plot_thompson. That is specify as option:" in ye " plot_thompson stacked"
	}

	if "`plot_thompson'" != "" {

	display "Distribution by batch for Thompson sampling"
		* Get alpha and beta values if it is a thompson algorithm
	python: ts_list = get_alpha_beta(reward, chosen_arm, batch)
		* store values for thompson
	python: Matrix.store("alpha_list", ts_list["alpha_list_cum"])
	python: Matrix.store("beta_list", ts_list["beta_list_cum"])

	local batch_size = rowsof(beta_list)
	local arms = colsof(beta_list)
	di "Number of arms: " + `arms'
	di "Number of batches: " + `batch_size'

		forvalues i =1/`batch_size' {
					local name =  "t" + "`i'"
					di "`name'"


					if "`stacked'" == ""{
						local beta_densities
						local legend_label
						forv j=1/`=`arms'' {
							local beta_densities `beta_densities' (function y=betaden(alpha_list[`i', `j'], beta_list[`i', `j'] , x), range(0 1))
							local legend_label `legend_label' label(`j' "Arm `=`j'-1'")

						   }

						twoway `beta_densities' ///
							   , ///
								name("`name'", replace) legend( `legend_label') `=`"`twoptions_thompson'"''

					}
					if "`stacked'" != ""{
				local combine
				forv j=1/`=`arms'' {

							tw (function y = betaden(alpha_list[`i', `j'], beta_list[`i', `j'],x), range(0.01 0.99) lwidth(medthick)),  ///
						   ytitle("B(`=round(alpha_list[`i', `j'])',`=round(beta_list[`i', `j'])')" "density") ylabel(#0, nolabels nogrid) xlabel(, nogrid) xtitle(Share of successes) plotr(m(zero)) name("g_`j'_`i'", replace) nodraw  `=`"`twoptions_thompson'"'' /**/
							local combine "`combine' g_`j'_`i'"
					   }
						gr combine `combine', xcommon col(1) iscale(1) name("combine_`i'", replace)
						}

				}

}

*** Histogram shares ***

	tempname res armnr share share2 share_ref N fixed max rewards rewards_bandits rewards_balanced // temp variable leads to not returning the stata estimates

	mat `res' = e(res)
	mat `armnr' = .

qui tab chosen_arm, matcell(`share')
	mat `rewards' = `res'[1...,1]

	mata : st_matrix("`N'", colsum(st_matrix("`share'")))

	mat `fixed' = `N'[1,1]*`=1/(`=rowsof(`res')+1')'

	mata : st_matrix("`max'", colmax(st_matrix("`rewards'"))) // Returns maximum reward value
	mata : st_matrix("`share'", st_matrix("`share'")/colsum(st_matrix("`share'")))

if "`no_plot'" == "" {
	qui levelsof chosen_arm

	qui hist chosen_arm , discrete frac xtitle(Treatment Arm) xlabel(0(1)`=max(`=subinstr("`r(levels)'"," ",",",.)')', valuelabel angle(90)) ylabel(#10) xlabel(,nogrid) ytitle("Share of Arm Selected") name("ShareArmSelected", replace) `=`"`histoptions'"''
}

*** Main table ***
        qui su `1' if chosen_arm ==`reference_arm'
        scalar refmean = r(mean)
        mat  `rewards'=`rewards'+J(`=rowsof(`res')', 1, refmean)


* OLS regression change reference arm
	qui reg `1' ib(`reference_arm').chosen_arm, vce(robust)

	*matrix list `share'
*** Remove reference arm from OLS results.
* Depending on whether the reference arm is first (base category),
* last, or in the middle, drop the corresponding row/column from
* the variance–covariance matrix e(V), the coefficient vector e(b),
* and the arm-share vector. Store non-reference-arm results in A,
* b_ols, and share2, and keep the reference-arm share in share_ref.
* case separation necessary because of allowed matrix operations (negative indexing does not work)
	if `reference_arm' == 0 {
	di "Reference arm: `reference_arm'"
    // base category is not in e(V)
    matrix A = e(V)[2..., 2...]
	matrix b_ols = e(b)'
	matrix b_ols = b_ols[2..., 1...]

	* drop the reference arm from the share
	mat `share2'=`share'[2...,1]
	mat `share_ref' = `share'[1,1]

}
	else if `reference_arm' == rowsof(`res') {
	di "Reference arm: `reference_arm'"

		matrix A = ///
    e(V)[1..`reference_arm', 1..`reference_arm'] , ///
    e(V)[1..`reference_arm', `=`reference_arm'+2'...]
matrix A = A \ ///
    ( e(V)[`=`reference_arm'+2'..., 1..`reference_arm'] , ///
      e(V)[`=`reference_arm'+2'..., `=`reference_arm'+2'...] )

* store OLS stats in named matrices (not tempnames, we reuse them)
    matrix b_ols = e(b)'
	* adept b_ols
	matrix b_ols = ///
    b_ols[1..`reference_arm', 1...] \ ///
    b_ols[`=`reference_arm'+2'..rowsof(b_ols), 1...]


	* drop the reference arm from the share
	mat `share2'=`share'[1..`reference_arm',1] // because of this snippet different if command necessary
	mat `share_ref' = `share'[`=`reference_arm'+1',1]

	}
	else{
	di "Reference arm: `reference_arm'"
	matrix A = ///
    e(V)[1..`reference_arm', 1..`reference_arm'] , ///
    e(V)[1..`reference_arm', `=`reference_arm'+2'...]
matrix A = A \ ///
    ( e(V)[`=`reference_arm'+2'..., 1..`reference_arm'] , ///
      e(V)[`=`reference_arm'+2'..., `=`reference_arm'+2'...] )

* store OLS stats in named matrices (not tempnames, we reuse them)
    matrix b_ols = e(b)'
	* adept b_ols
	matrix b_ols = ///
    b_ols[1..`reference_arm', 1...] \ ///
    b_ols[`=`reference_arm'+2'..rowsof(b_ols), 1...]

	* drop reference arm from share and create share matrix for ref arm
	mat `share2' = `share'[1..`reference_arm', 1...] \ ///
    `share'[`=`reference_arm'+2'..rowsof(`share'), 1...]
	mat `share_ref' = `share'[`=`reference_arm'+1',1]

	}

	* calculate specific summary stats
	mata : st_matrix("se_ols", sqrt(diagonal(st_matrix("A"))))

	*matrix list `share2'
	scalar df_ols = e(df_r)
    local alpha = 0.05

    * t-stats and p-values
    matrix t_ols = J(rowsof(b_ols), 1, .)
    matrix p_ols = J(rowsof(b_ols), 1, .)

    forvalues j = 1/`=rowsof(b_ols)' {      // row 1 = intercept (arm b), j>1 = arm j-1 vs b
        scalar t_j = (b_ols[`j',1]- `test_value') / se_ols[`j',1]
        scalar p_j = 2*ttail(df_ols, abs(t_j))
        matrix t_ols[`j',1] = t_j
        matrix p_ols[`j',1] = p_j
    }

    * 95% robust CI
    matrix ciL_ols = b_ols - invttail(df_ols, 0.5*`alpha')*se_ols
    matrix ciU_ols = b_ols + invttail(df_ols, 0.5*`alpha')*se_ols

* =========================
* Compact stacked summary
* =========================

	di in smcl in gr "{hline 71}"

* ---- Totals (left) and means (right) ----
di in smcl in gr ///
    _col(1)  "N"               _col(18) "= " in ye %8.0f `N'[1,1]

di in smcl in gr ///
    _col(1)  "Best-arm total"  _col(18) "= " in ye %8.0f mat_add_stats[2,1] ///
    _col(40) "Best-arm mean"   _col(58) "= " in ye %8.4f mat_add_stats[1,1]

di in smcl in gr ///
    _col(1)  "Actual total"    _col(18) "= " in ye %8.0f mat_add_stats[4,1] ///
    _col(40) "Actual mean"     _col(58) "= " in ye %8.4f mat_add_stats[3,1]

di in smcl in gr ///
    _col(1)  "Uniform total"   _col(18) "= " in ye %8.0f mat_add_stats[6,1] ///
    _col(40) "Uniform mean"    _col(58) "= " in ye %8.4f mat_add_stats[5,1]

* ========== Share b + comparison table as ONE box ==========
* Top border with junction at column 9
di in smcl in gr "{hline 8}{c TT}{hline 62}"

* Header for arm b line
di in smcl in gr ///
    "Share b" ///
    _col(9)  "{c |}" ///
    _col(11) "Mean reward arm b"

* Line between header and value
di in smcl in gr "{hline 8}{c +}{hline 62}"

* Arm b values (0.1606 aligned with Marg. below at col 21)
di in smcl ///
    _col(1)  in ye %6.4f `share_ref'[1,1] ///
    _col(9)  in gr "{c |}" ///
    _col(21) in ye %7.4f refmean

* Line between arm b row and main header
di in smcl in gr "{hline 8}{c +}{hline 62}"

* Main header (Share k etc.)
di in smcl in gr ///
    "Share k" ///
    _col(9)  "{c |}" ///
    _col(11) "Arm/Est." ///
    _col(21) "Marg." ///
    _col(30) "Robust SE" ///
    _col(38) "  z" ///
    _col(45) "P>|z|" ///
    _col(54) "[95% CI]"

* Line between header and data rows
di in smcl in gr "{hline 8}{c +}{hline 62}"

forvalues i = 1/`=rowsof(`res')' {

	local arm_val = `res'[`i',10]
	local arm_lab "`arm_val'-`reference_arm'"
	*local arm_lab "`=`res'[`i',10]'" "-" "`reference_arm'"
    *local share_k = `share'[`=`i'+1',1]
	local share_k = `share2'[`i',1]
    * BOLS SE from CI
    *scalar se_bols = (`res'[`i',6] - `res'[`i',5]) / (2*invnormal(0.975))
	scalar se_bols = `res'[`i', 9]

    * ---- Share row for arm k ----
    di in smcl ///
        _col(1)  in ye %6.4f `share_k' ///
        _col(9)  in gr "{c |}" ///
        _col(11) in ye "`arm_lab'"


    * ---- OLS row (robust) ----
	local r = `i' // just loop over i
    di in smcl ///
        _col(1)  " " ///
        _col(9)  in gr "{c |}" ///
        _col(11) in gr "OLS" ///
        _col(21) in ye %7.4f `res'[`i',1]          /// margin OLS (from Python)
        _col(30) in ye %7.4f se_ols[`r',1]         /// robust SE
        _col(38) in ye %6.2f t_ols[`r',1]          /// t (you can still label the column "z")
        _col(45) in ye %6.3f p_ols[`r',1]          /// P>|t|
        _col(54) in ye "[" %6.4f ciL_ols[`r',1] ", " %6.4f ciU_ols[`r',1] "]"


    * ---- BOLS row ----
    di in smcl ///
        _col(1)  " " ///
        _col(9)  in gr "{c |}" ///
        _col(11) in gr "BOLS" ///
        _col(21) in ye %7.4f `res'[`i',2] ///
        _col(30) in ye %7.4f se_bols ///
        _col(38) in ye %6.2f `res'[`i',3] ///
        _col(45) in ye %6.3f `res'[`i',4] ///
        _col(54) in ye "[" %6.4f `res'[`i',5] ", " %6.4f `res'[`i',6] "]"

    mat `armnr' = `armnr' \ `i'
}

* Bottom border with junction at column 9
di in smcl in gr "{hline 8}{c BT}{hline 62}"



*** Add conf bands to res matrix - used for ereturn and ols graph
 	tempname ols_ci_l ols_ci_u arm se
	local alpha = 0.05

	mat `ols_ci_l' = b_ols - invttail(e(N)-1,0.5*`alpha')*se_ols
	mat `ols_ci_u' = b_ols + invttail(e(N)-1,0.5*`alpha')*se_ols
	mata : st_matrix("`res'", sort(st_matrix("`res'"), 10))
	*mat `res' = `res' , `ols_ci_l'[2..`=rowsof(`ols_ci_l')-1', 1], `ols_ci_u'[2..`=rowsof(`ols_ci_u')-1', 1]
	mat `res' = `res' , `ols_ci_l'[1..`=rowsof(`ols_ci_l')-1', 1], `ols_ci_u'[1..`=rowsof(`ols_ci_u')-1', 1]
* sort matrix by highest estimate for coefficients plots
mata : st_matrix("`res'", sort(st_matrix("`res'"), 2))

*****************************************************************
********************** Plots ************************************
*****************************************************************

*** BOLS graph ***
if "`no_plot'" == "" { // only when plots allowed
	tempname bols ols ci_l ci_u arm

	qui gen `bols' = `res'[_n,2] in 1/`=rowsof(`res')'
	qui gen `ols' = `res'[_n,1] in 1/`=rowsof(`res')'
	qui gen `ci_l' = `res'[_n,5] in 1/`=rowsof(`res')'
	qui gen `ci_u' = `res'[_n,6] in 1/`=rowsof(`res')'
	qui gen `arm' = _n in 1/`=rowsof(`res')'
	scalar best = `=`res'[`=rowsof(`res')',10]'
	scalar worst = `=`res'[1,10]'

	local xlab ""
		forvalues i = 1/`=rowsof(`res')' {
			local xlab "`xlab' `i' "`""`=`res'[`i',10]'""'" "
		}
		local x: di "`xlab'"

twoway (scatter `bols' `arm', /*mcolor("142 69 97")*/) ///
       (rcap `ci_l' `ci_u' `arm', /*lcolor("142 69 97")*/) ///
       , legend(off ) yline(0, lpattern(dash) /*lcolor("85 164 168")*/) ///
       xlabel(`x', noticks) ylabel(#6, grid) ///
       xtitle("Arm") ytitle("Treatment Effect") plotregion(margin(b=0)) name("BOLS", replace) ///
	   `=`"`twoptions_bols'"''


*** OLS graph ***

	tempname ci_l_ols ci_u_ols arm2 arm

	qui gen `ci_l_ols' = `res'[_n,11] in 1/`=rowsof(`res')'
	qui gen `ci_u_ols' = `res'[_n,12] in 1/`=rowsof(`res')'
	qui gen `arm' = _n in 1/`=rowsof(`res')'
	qui gen `arm2' = `arm'+0.2 in 1/`=rowsof(`res')'

twoway (scatter `ols' `arm2', /*mcolor(gs13) m(D)*/) ///
       (rcap `ci_l_ols' `ci_u_ols' `arm2', /*lcolor(gs13)*/) ///
	   (scatter `bols' `arm', /*mcolor("142 69 97")*/) ///
       (rcap `ci_l' `ci_u' `arm', /*lcolor("142 69 97")*/) ///
       , legend(order(1 3) label(1 "OLS") label(3 "BOLS") ) yline(0, lpattern(dash) /*lcolor("85 164 168")*/) ///
       xlabel(`x', noticks) ylabel(#6, grid) ///
       xtitle("Arm") ytitle("Treatment Effect") plotregion(margin(b=0)) name("OLS", replace) ///
	    `=`"`twoptions_ols'"''


*** Share by batch graph ***
tempname all total share cumshare batch_enc

capture confirm numeric variable `var3'
if !_rc {
    gen `batch_enc' = `var3'
}
else {
    gen `batch_enc' = 0
    qui replace `batch_enc' = 1 if `var3'[_n] ~= `var3'[_n-1]
    qui replace `batch_enc' = sum(`batch_enc')
}


gen `all'=1
preserve
collapse (mean) `1' (count) `all' , by(`batch_enc' chosen_arm)
bys `batch_enc': egen `total'=total(`all')
gen `share'= `all'/`total'
bys `batch_enc': gen `cumshare' = sum(`share')

local area ""
local line ""
	forvalues i = `=rowsof(`res')'(-1)0 {
		local area "`area' (area `cumshare' `batch_enc' if chosen_arm ==`i', fintensity(100))"
		local line "`line' (line `share' `batch_enc' if chosen_arm ==`i')"
	}

qui levelsof `batch_enc'

qui sum `batch_enc'
tw `line', legend(off) ytitle(Share) xtitle(Batch) ylabel(0(.2)1) xlabel(`r(min)'(1)`r(max)') name("ShareByBatch", replace) ///
`=`"`twoptions_sharebybatch'"''

*** Stacked share by batch graph ***
qui sum `batch_enc'
tw `area', legend(off) ytitle(Share) xtitle(Batch) ylabel(0(.2)1) xlabel(`r(min)'(1)`r(max)') name("StackedShareByBatch", replace) ///
`=`"`twoptions_stackedsharebybatch'"''

*** Cumulative share by batch graph ***
tempname cumall cumttotal cumshare cumshare2

sort chosen_arm `batch_enc'
by chosen_arm: gen `cumall' = sum(`all')
bys `batch_enc' : egen `cumttotal'=total(`cumall')


gen `cumshare'= `cumall'/`cumttotal'
sort `batch_enc' chosen_arm
by `batch_enc' : gen `cumshare2' = sum(`cumshare')


local area ""
	forvalues i = `=rowsof(`res')'(-1)0 {
		local area "`area' (area `cumshare2' `batch_enc' if chosen_arm ==`i', fintensity(100))"
	}

qui levelsof `batch_enc'
tokenize "`r(levels)'"
tw `area', legend(off) ytitle(Share) xtitle(Batch) ylabel(0(.2)1) xlabel(`1'(1)`=`1'+r(r)-1') name("CumSharesByBatch", replace) ///
`=`"`twoptions_cumsharesbyybatch'"''

} // end noplot graph
*** restore the main results ***
// Assign new column names to the matrix 'res'
* drop generated matrices for plots etc.
capture matrix drop ciU_ols ciL_ols p_ols t_ols se_ols b_ols A

matrix colnames `res' = "margin OLS" "margin OLS BOLS" "z" "p-value" ///
                      "95% BOLS conf lower bound"   "95% conf upper bound" "obs reference arm" ///
                      "obs treatment arm" "Standard Error BOLS" "treatment arm indicator" ///
                      "95% OLS conf lower bound" "95% OLS conf upper bound"

* Return results
ereturn matrix res `res'
ereturn matrix batch_ols_coefficients `batch_ols_coefficients'
ereturn matrix batched_ols_weights `batched_ols_weights'
ereturn matrix reward_evaluation mat_add_stats
* remove mat add stats
capture matrix drop mat_add_stats

end