<?xml version="1.0" encoding="UTF-8"?>
<rss  xmlns:atom="http://www.w3.org/2005/Atom" 
      xmlns:media="http://search.yahoo.com/mrss/" 
      xmlns:content="http://purl.org/rss/1.0/modules/content/" 
      xmlns:dc="http://purl.org/dc/elements/1.1/" 
      version="2.0">
<channel>
<title>Sulgi Kim</title>
<link>https://sulgik.github.io/</link>
<atom:link href="https://sulgik.github.io/index.xml" rel="self" type="application/rss+xml"/>
<description>Notes on experiments, bandits and statistics.</description>
<generator>quarto-1.10.19</generator>
<lastBuildDate>Thu, 15 Oct 2026 00:00:00 GMT</lastBuildDate>
<item>
  <title>모두가 함께 움직일 때, 무엇을 기억해야 하나</title>
  <link>https://sulgik.github.io/posts/2026-10-what-to-remember/</link>
  <description><![CDATA[ 





<p><a href="../2026-10-what-to-remember-en/">English version</a></p>
<section id="여러-분야에-같은-구조가-있다" class="level2">
<h2 class="anchored" data-anchor-id="여러-분야에-같은-구조가-있다">여러 분야에 같은 구조가 있다</h2>
<p>비교 실험을 하다 보면, 비교하려는 차이와 상관없이 모든 대상이 함께 움직이는 순간이 온다.</p>
<ul>
<li><strong>온라인 A/B 테스트</strong>: 프로모션, 요일, 앱 릴리스가 모든 변형의 전환율을 같이 올리고 내린다.</li>
<li><strong>광고</strong>: 시간대와 계절이 모든 소재의 클릭률을 같이 흔든다.</li>
<li><strong>이메일과 푸시</strong>: 보내는 요일과 시각이 모든 문구의 반응률을 같이 바꾼다.</li>
<li><strong>모바일 헬스</strong>: 그날의 컨디션과 일정이 어떤 알림을 받든 반응 확률 전체를 움직인다.</li>
<li><strong>장기간의 임상시험</strong>: 등록 시기와 기관, 표준 치료의 변화가 모든 군의 결과를 함께 바꾼다.</li>
<li><strong>농업 현장 시험</strong>: 밭의 구획마다 토양이 달라, 같은 구획의 모든 품종이 함께 잘 되거나 못 된다.</li>
</ul>
<p>마지막 예가 실마리다. 통계학의 가장 오래된 답 중 하나가 블록화다. 같은 구획 안에서만 비교하고, 구획 자체의 효과는 비교에서 빼 버린다. 모두를 함께 움직이는 것은 “수준” 이고, 알고 싶은 것은 그 위의 “차이” 다.</p>
<p>차이를 어떤 척도로 재느냐도 중요하다. 수준이 움직일 때 확률 차이와 오즈비가 동시에 고정되어 있을 수는 없다. 임상시험 메타분석에서는 확률 차이보다 오즈비 같은 상대 척도가 기저 위험이 다른 연구들 사이에서 더 일정하다는 보고가 오래전부터 있었다(<a href="https://doi.org/10.1002/sim.1188">Deeks, 2002</a>). 아래의 방법은 이 상대 척도, 로그오즈 위에서 “차이는 유지되고 수준은 움직인다” 고 가정한다.</p>
</section>
<section id="적응적-배정은-이-문제를-키운다" class="level2">
<h2 class="anchored" data-anchor-id="적응적-배정은-이-문제를-키운다">적응적 배정은 이 문제를 키운다</h2>
<p>트래픽을 반반으로 고정한 실험이라면 수준이 움직여도 크게 문제가 없다. 모든 변형이 같은 시기를 같은 비율로 겪기 때문에 영향이 상쇄된다.</p>
<p>밴딧은 다르다. 더 좋아 보이는 쪽으로 트래픽을 옮기는 것이 본래 하는 일이다. 그러면 변형마다 겪은 시기가 달라진다. 전 기간을 합친 전환율은, 좋은 시기에 트래픽을 더 받은 변형에 유리하게 기운다. 실력이 아니라 운이 순위를 바꾼다.</p>
<p>블록화를 밴딧에 옮기면 답이 나온다. 배치 하나를 블록 하나로 본다. 배치마다 로지스틱 회귀를 적합하되 그 배치의 수준은 별도의 절편으로 흡수해 버리고, 변형 사이의 로그오즈 차이만 다음 배치로 넘긴다. 내가 <a href="https://arxiv.org/abs/2609.19709">Odds-Ratio Thompson Sampling</a>(OR-TS)이라는 이름으로 정리하고 <a href="https://pypi.org/project/orts/"><code>pip install orts</code></a> 로 공개한 방법이다.</p>
</section>
<section id="측정해-보면-맞는-곳과-아닌-곳" class="level2">
<h2 class="anchored" data-anchor-id="측정해-보면-맞는-곳과-아닌-곳">측정해 보면: 맞는 곳과 아닌 곳</h2>
<p>이 가정이 실제로 맞는지는 측정할 수 있다. 기간마다 수준과 차이를 추정하고, 표본 잡음을 넘어서 각각이 얼마나 움직이는지 보면 된다. 그런 측정이 가능한 공개 데이터는 많지 않았고, 두 개를 썼다.</p>
<ul>
<li><strong>맞았다: 한 온라인 소매업체의 A/B 테스트 71개</strong>(ASOS 공개 데이터). 기간 사이에 수준은 로짓으로 약 3분의 1만큼 움직였고, 변형 간 차이의 크기는 그 100분의 1 수준이었다. 86개 시계열 모두에서 수준이 차이보다 더 움직였다. 다만 차이가 오랫동안 유지되는지는 이 데이터만으로 확정되지 않았다.</li>
<li><strong>틀렸다: 패션 추천 로그 하나</strong>(Open Bandit 데이터). 여기서는 오히려 차이가 수준보다 더 움직였다. 아이템 몇 개의 인기가 일주일 사이에 오르내렸기 때문이다. 이 환경에서는 어떤 방법도 다른 방법보다 뚜렷이 낫지 않았다.</li>
</ul>
<p>즉 이것은 모든 분야에 맞는 법칙이 아니라 분야마다 확인할 성질이다. 그리고 확인하는 데 오래 걸리지 않는다.</p>
</section>
<section id="당신의-데이터에서-확인하는-법" class="level2">
<h2 class="anchored" data-anchor-id="당신의-데이터에서-확인하는-법">당신의 데이터에서 확인하는 법</h2>
<p>기간과 변형별로 노출 수와 이벤트 수만 있으면 된다. 실험 플랫폼의 집계 테이블이면 충분하다.</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb1" style="background: #f1f3f5;"><pre class="sourceCode python code-with-copy"><code class="sourceCode python"><span id="cb1-1"><span class="im" style="color: #00769E;
background-color: null;
font-style: inherit;">from</span> orts <span class="im" style="color: #00769E;
background-color: null;
font-style: inherit;">import</span> batches_from_rows</span>
<span id="cb1-2"><span class="im" style="color: #00769E;
background-color: null;
font-style: inherit;">from</span> orts.diagnostics <span class="im" style="color: #00769E;
background-color: null;
font-style: inherit;">import</span> batch_contrasts, excess_sd</span>
<span id="cb1-3"></span>
<span id="cb1-4">levels, contrasts <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> [], []</span>
<span id="cb1-5"><span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">for</span> obs <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">in</span> batches_from_rows(rows):          <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># 기간×변형 한 행씩</span></span>
<span id="cb1-6">    level, effects <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> batch_contrasts(obs, reference<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"control"</span>)</span>
<span id="cb1-7">    levels.append(level)</span>
<span id="cb1-8">    contrasts.append(effects[<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"treatment"</span>])</span>
<span id="cb1-9"></span>
<span id="cb1-10"><span class="bu" style="color: null;
background-color: null;
font-style: inherit;">print</span>(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"수준이 움직인 정도"</span>, excess_sd(<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">*</span><span class="bu" style="color: null;
background-color: null;
font-style: inherit;">zip</span>(<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">*</span>levels)))</span>
<span id="cb1-11"><span class="bu" style="color: null;
background-color: null;
font-style: inherit;">print</span>(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"차이가 움직인 정도"</span>, excess_sd(<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">*</span><span class="bu" style="color: null;
background-color: null;
font-style: inherit;">zip</span>(<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">*</span>contrasts)))</span></code></pre></div></div>
<p>두 숫자는 표본 잡음을 뺀 기간 사이의 표준편차이고, 단위는 로짓이다. 첫 번째가 두 번째보다 훨씬 크다면, 그 분야에서는 수준을 버리고 차이만 기억하는 쪽이 맞는 선택이다. 두 번째가 크다면 차이도 시간에 따라 움직인다는 뜻이고, 그때는 과거를 조금씩 잊는 장치(<code>decay</code>)가 필요하다.</p>
</section>
<section id="실제-제품-코드와의-비교" class="level2">
<h2 class="anchored" data-anchor-id="실제-제품-코드와의-비교">실제 제품 코드와의 비교</h2>
<p>가정이 맞는 환경에서 얼마나 차이가 나는지는, 오픈소스 실험 플랫폼 GrowthBook 의 밴딧 엔진과 비교해 봤다. 엔진 코드가 공개되어 있어서 실제 제품 코드를 그대로 돌릴 수 있었다. 이 엔진은 기간마다 평균을 구해 모든 변형에 같은 가중치로 합치는 방식, 즉 블록화의 다른 형태를 쓴다.</p>
<p>이진 결과, 기간 40개에 기간당 2만 명, 변형 다섯 개, 20회 반복. 누적 regret 은 항상 최선의 변형만 보여 줬을 때와 비교해 잃은 전환 수이며 낮을수록 좋다.</p>
<table class="caption-top table">
<thead>
<tr class="header">
<th></th>
<th style="text-align: right;">공통 시프트</th>
<th style="text-align: right;">시프트 없음</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td>기간 가중 (GrowthBook)</td>
<td style="text-align: right;">642</td>
<td style="text-align: right;">622</td>
</tr>
<tr class="even">
<td>가중 없이 전 기간 합산</td>
<td style="text-align: right;">798</td>
<td style="text-align: right;">400</td>
</tr>
<tr class="odd">
<td>기간마다 수준을 버림 (OR-TS)</td>
<td style="text-align: right;">421</td>
<td style="text-align: right;">360</td>
</tr>
</tbody>
</table>
<p>기간 가중은 시프트를 막아 주지만(642 대 798), 아무것도 움직이지 않을 때는 합산보다 못하다(622 대 400). 가중치가 그 변형이 받은 사용자 수가 아니라 전체 트래픽 비중을 따르기 때문이다. 수준을 버리는 방식은 각 관측을 담은 정보만큼 가중하므로 그 대가 없이 보호를 얻는다.</p>
<p>사용자 집단마다 최선의 변형이 다른 경우로도 넓혀 봤다. 집단과 기간마다 수준을 버리고 집단마다 차이를 두되, 얇은 집단은 다른 집단에서 정보를 빌려 오게 했다. 결과를 보기 전에 가설 네 개와 실패 기준을 <a href="https://github.com/sulgik/orts/blob/main/benchmarks/growthbook/PREREG_contextual_robustness.md">먼저 커밋하고</a> 돌렸다. 셋은 성립했고 하나는 실패했다. 실패한 것은 결과 수준으로 집단을 나누는 트리의 리프를 그대로 집단으로 쓰는 방식이었다. 최선의 변형이 다른 집단이 한 리프에 묶이면 다시 가를 수 없다. 블록은 결과가 아니라 비교 구조에 맞춰 나눠야 한다는, 블록화의 원래 교훈과 같은 이야기다.</p>
</section>
<section id="이-글이-말하는-것과-말하지-않는-것" class="level2">
<h2 class="anchored" data-anchor-id="이-글이-말하는-것과-말하지-않는-것">이 글이 말하는 것과 말하지 않는 것</h2>
<p>비교는 시뮬레이션이고, 이진 결과뿐이다. 실제 데이터 측정은 두 데이터셋에서 했고, 하나에서는 가정이 맞고 하나에서는 틀렸다. 광고, 모바일 헬스, 임상시험, 현장 시험은 구조가 같다는 이야기이지, 그 분야에서 측정했다는 이야기가 아니다. 그 분야의 데이터를 가진 사람이 위의 진단을 돌려 본다면, 그것이 이 방법이 어디까지 맞는지에 대한 가장 좋은 답이 될 것이다.</p>
<p>기간 가중이 틀렸다는 이야기도 아니다. 평균 메트릭에도 쓰이고 계산이 단순하다. 수준을 버리는 방식은 지금은 이진 결과에만 있다.</p>
<p>엔진을 공개해 두고 내 제안을 꼼꼼히 검토해 준 GrowthBook 팀에 감사한다.</p>


</section>

 ]]></description>
  <category>bandits</category>
  <category>experiments</category>
  <category>한국어</category>
  <guid>https://sulgik.github.io/posts/2026-10-what-to-remember/</guid>
  <pubDate>Thu, 15 Oct 2026 00:00:00 GMT</pubDate>
</item>
<item>
  <title>When everything moves together, what should you remember?</title>
  <link>https://sulgik.github.io/posts/2026-10-what-to-remember-en/</link>
  <description><![CDATA[ 





<p><a href="../2026-10-what-to-remember/">한국어판</a></p>
<section id="the-same-structure-in-many-fields" class="level2">
<h2 class="anchored" data-anchor-id="the-same-structure-in-many-fields">The same structure, in many fields</h2>
<p>Run a comparative experiment long enough and something will move everything you are comparing, regardless of the difference you care about.</p>
<ul>
<li><strong>Online A/B tests</strong>: promotions, weekdays and app releases lift or drop every variation’s conversion rate together.</li>
<li><strong>Advertising</strong>: time of day and season shake every creative’s click rate.</li>
<li><strong>Email and push</strong>: the day and hour of sending move every message’s response rate.</li>
<li><strong>Mobile health</strong>: a person’s day, mood and schedule move how likely they are to respond to any prompt.</li>
<li><strong>Long clinical trials</strong>: calendar time, site and changes in standard of care shift outcomes in every arm.</li>
<li><strong>Agricultural field trials</strong>: soil differs from plot to plot, so every variety in a good block does well together.</li>
</ul>
<p>The last one is the clue. One of the oldest answers in statistics is blocking: compare only within a block, and take the block’s own effect out of the comparison. What moves everything is the <em>level</em>; what you want to know is the <em>difference</em> on top of it.</p>
<p>The scale of that difference matters too. When the level moves, a difference in probabilities and an odds ratio cannot both stay fixed. In meta-analyses of clinical trials, relative measures such as the odds ratio have long been reported to be more consistent than risk differences across studies with different baseline risk (<a href="https://doi.org/10.1002/sim.1188">Deeks, 2002</a>). The method below makes its bet on that relative scale: on the log-odds scale, the differences hold while the level moves.</p>
</section>
<section id="adaptive-allocation-makes-it-worse" class="level2">
<h2 class="anchored" data-anchor-id="adaptive-allocation-makes-it-worse">Adaptive allocation makes it worse</h2>
<p>With a fixed 50/50 split, a moving level does little harm. Every variation goes through the same periods in the same proportion, so the movement cancels.</p>
<p>A bandit is different. Moving traffic toward whatever looks better is its whole job, so each variation ends up living through different periods. Rates pooled over the run tilt toward whichever variation got more traffic in the good stretch. Luck, not quality, reorders them.</p>
<p>Carrying blocking over to bandits gives the answer. Treat each batch as a block. Fit a logistic regression to the batch, let a separate intercept absorb that batch’s level and throw it away, and carry only the log-odds differences between variations to the next batch. I wrote this up as <a href="https://arxiv.org/abs/2609.19709">Odds-Ratio Thompson Sampling</a> (OR-TS) and released it as <a href="https://pypi.org/project/orts/"><code>pip install orts</code></a>.</p>
</section>
<section id="measured-where-it-holds-and-where-it-doesnt" class="level2">
<h2 class="anchored" data-anchor-id="measured-where-it-holds-and-where-it-doesnt">Measured: where it holds and where it doesn’t</h2>
<p>Whether the bet holds can be measured: estimate the level and the differences in every period, and see how much each moves beyond sampling noise. Few public datasets allow this. I used two.</p>
<ul>
<li><strong>It held: 71 A/B tests from one online retailer</strong> (the public ASOS dataset). Between periods the level moved by about a third of a logit, while the differences between variations were around a hundredth of that in size. In all 86 series the level moved more than the difference. Whether the differences persist over long spans is not settled by this data.</li>
<li><strong>It failed: one fashion recommendation log</strong> (the Open Bandit dataset). Here the differences moved more than the level, because a few items rose and fell in popularity within the week. In that environment no method was clearly better than another.</li>
</ul>
<p>So this is not a law that holds everywhere. It is a property to check in each field, and checking takes minutes.</p>
</section>
<section id="how-to-check-your-own-data" class="level2">
<h2 class="anchored" data-anchor-id="how-to-check-your-own-data">How to check your own data</h2>
<p>All you need is exposures and events per period and variation, which is what an experimentation platform’s aggregate table already holds.</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb1" style="background: #f1f3f5;"><pre class="sourceCode python code-with-copy"><code class="sourceCode python"><span id="cb1-1"><span class="im" style="color: #00769E;
background-color: null;
font-style: inherit;">from</span> orts <span class="im" style="color: #00769E;
background-color: null;
font-style: inherit;">import</span> batches_from_rows</span>
<span id="cb1-2"><span class="im" style="color: #00769E;
background-color: null;
font-style: inherit;">from</span> orts.diagnostics <span class="im" style="color: #00769E;
background-color: null;
font-style: inherit;">import</span> batch_contrasts, excess_sd</span>
<span id="cb1-3"></span>
<span id="cb1-4">levels, contrasts <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> [], []</span>
<span id="cb1-5"><span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">for</span> obs <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">in</span> batches_from_rows(rows):          <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># one row per (period, variation)</span></span>
<span id="cb1-6">    level, effects <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> batch_contrasts(obs, reference<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"control"</span>)</span>
<span id="cb1-7">    levels.append(level)</span>
<span id="cb1-8">    contrasts.append(effects[<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"treatment"</span>])</span>
<span id="cb1-9"></span>
<span id="cb1-10"><span class="bu" style="color: null;
background-color: null;
font-style: inherit;">print</span>(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"level moves by     "</span>, excess_sd(<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">*</span><span class="bu" style="color: null;
background-color: null;
font-style: inherit;">zip</span>(<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">*</span>levels)))</span>
<span id="cb1-11"><span class="bu" style="color: null;
background-color: null;
font-style: inherit;">print</span>(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"difference moves by"</span>, excess_sd(<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">*</span><span class="bu" style="color: null;
background-color: null;
font-style: inherit;">zip</span>(<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">*</span>contrasts)))</span></code></pre></div></div>
<p>Both numbers are the standard deviation across periods with sampling noise removed, in logits. If the first is much larger than the second, throwing away the level and remembering only the differences is the right bet in your setting. If the second is large, the differences move over time as well, and you need a way to forget the past gradually (<code>decay</code>).</p>
</section>
<section id="against-real-product-code" class="level2">
<h2 class="anchored" data-anchor-id="against-real-product-code">Against real product code</h2>
<p>To see how much it matters where the bet holds, I compared against the bandit engine of GrowthBook, an open-source experimentation platform. Because the engine is public, I could run the actual product code. It uses another form of blocking: take each period’s mean and combine the periods with the same weights for every variation.</p>
<p>Binary outcomes, 40 periods of 20,000 users, five variations, 20 runs. Cumulative regret counts the conversions lost against always serving the best variation; lower is better.</p>
<table class="caption-top table">
<thead>
<tr class="header">
<th></th>
<th style="text-align: right;">Common shifts</th>
<th style="text-align: right;">No shifts</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td>Period weighting (GrowthBook)</td>
<td style="text-align: right;">642</td>
<td style="text-align: right;">622</td>
</tr>
<tr class="even">
<td>Pooled over the run, no weighting</td>
<td style="text-align: right;">798</td>
<td style="text-align: right;">400</td>
</tr>
<tr class="odd">
<td>Level dropped every period (OR-TS)</td>
<td style="text-align: right;">421</td>
<td style="text-align: right;">360</td>
</tr>
</tbody>
</table>
<p>Period weighting protects against shifts (642 against 798) but does worse than pooling when nothing moves (622 against 400), because its weights follow each period’s share of all traffic rather than how many users that variation got. Dropping the level weights each observation by the information it carries, so it gets the protection without that price.</p>
<p>I also extended it to user groups whose best variation differs: drop the level for every group and period, keep a difference per group, and let thin groups borrow from the others. I <a href="https://github.com/sulgik/orts/blob/main/benchmarks/growthbook/PREREG_contextual_robustness.md">committed four hypotheses and their failure criteria</a> before running anything. Three held and one failed. The failure was using the leaves of a tree that splits on outcome levels as the groups: once groups with different best variations share a leaf, nothing can separate them again. It is the original lesson of blocking: form the blocks around the comparison, not around the outcome.</p>
</section>
<section id="what-this-is-and-isnt" class="level2">
<h2 class="anchored" data-anchor-id="what-this-is-and-isnt">What this is and isn’t</h2>
<p>The comparisons are simulations, with binary outcomes only. The real-data measurements cover two datasets; the bet held in one and failed in the other. Advertising, mobile health, clinical trials and field trials share the structure; I have not measured them. If someone with data from those fields runs the check above, that will be the best answer to how far this goes.</p>
<p>Nor does any of this say period weighting is wrong. It works for mean metrics too and is simple to compute. Dropping the level is, for now, only available for binary outcomes.</p>
<p>Thanks to the GrowthBook team for keeping their engine open and for reviewing my proposal carefully.</p>


</section>

 ]]></description>
  <category>bandits</category>
  <category>experiments</category>
  <category>English</category>
  <guid>https://sulgik.github.io/posts/2026-10-what-to-remember-en/</guid>
  <pubDate>Thu, 15 Oct 2026 00:00:00 GMT</pubDate>
</item>
</channel>
</rss>
