<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://petroskarypis.github.io/blog/feed.xml" rel="self" type="application/atom+xml" /><link href="https://petroskarypis.github.io/" rel="alternate" type="text/html" /><updated>2026-10-31T22:43:21+00:00</updated><id>https://petroskarypis.github.io/blog/feed.xml</id><title type="html">Petros Karypis</title><subtitle>Research notes on language model architecture.</subtitle><author><name>Petros Karypis</name></author><entry><title type="html">LeRoPE: letting RoPE learn its own frequencies</title><link href="https://petroskarypis.github.io/blog/2026/lerope/" rel="alternate" type="text/html" title="LeRoPE: letting RoPE learn its own frequencies" /><published>2026-10-29T00:00:00+00:00</published><updated>2026-10-29T00:00:00+00:00</updated><id>https://petroskarypis.github.io/blog/2026/lerope</id><content type="html" xml:base="https://petroskarypis.github.io/blog/2026/lerope/"><![CDATA[<p>With <a href="https://mistral.ai/news/mistral-large-4-oss/">Mistral Large 4</a> <a href="https://huggingface.co/blog/mistral-large4">adopting LeRoPE</a>, we figured it was a good time to write about it. <a href="https://arxiv.org/abs/2607.10134">LeRoPE</a> makes RoPE’s rotation frequencies learnable, along with the rest of the network.</p>

<p>This post is an overview of the method and what we found. If you only want to try it, <a href="#trying-it-yourself">skip to the code</a>.</p>

<blockquote>
  <p><strong>TL;DR: 32 parameters → 3.4% less pretraining compute.</strong></p>

  <ul>
    <li>LeRoPE learns one scale per RoPE frequency. That’s \(d_h/2\) extra parameters, a few lines of code, and no meaningful compute overhead.</li>
    <li>Across 176 pretrained models from 52M to 2.5B parameters, it beats both RoPE and p-RoPE at every scale. It saves 3.4% of pretraining compute at 2.5B as well as improving long-context retrieval.</li>
    <li>We found that generally fast rotations track RoPE, rotations slower than the training context get shut off, and one channel ends up carrying almost all the positional signal.</li>
    <li>If you pretrain a model with RoPE, it’s essentially free.</li>
  </ul>
</blockquote>

<h2 id="rope">RoPE</h2>

<p><a href="https://arxiv.org/abs/2104.09864">RoPE</a> rotates the queries and keys in attention according to their position in the sequence. Each 2-dimensional chunk (or channel) of a query or key rotates at its own fixed frequency, and the frequencies follow a geometric schedule set by a single hyperparameter, the base \(b\):</p>

\[\theta_m = b^{-2m/d_h}, \qquad m = 0, \dots, d_h/2 - 1.\]

<p>Because the rotations compose, the attention logit splits into one term per channel, and each term depends only on how far apart the query and key are. With 64-dimensional heads and the default \(b = 10{,}000\), the channels’ wavelengths (\(2\pi/\theta_m\)) run from about 6 tokens to about 47,000. In a 2,048-token context, the slowest 11 of the 32 channels never complete a single turn.</p>

<p>Prior work has found some benefit to having stable channels that don’t rotate (or at least not much). They’re useful for long-range semantic information, where the exact relative position doesn’t matter. So in practice people now either scale up \(b\), don’t rotate the slowest channels (<a href="https://arxiv.org/abs/2410.06205">p-RoPE</a>), or don’t apply RoPE altogether (NoPE). All of these help, but all of them pick the frequencies by hand. We wanted to see what the model would choose for itself.</p>

<h2 id="lerope">LeRoPE</h2>

<p>The method is one line:</p>

<blockquote>
\[\hat\theta_m = e^{\alpha_m}\,\theta_m ,\]
</blockquote>

<p>with one learned \(\alpha_m\) per channel, shared across all layers and heads, and initialized at zero so the model starts out as plain RoPE. Some practical notes from our experiments:</p>

<ul>
  <li><strong>Clip the frequency gradient separately.</strong> The gradient of the frequency grows with the context length. If it shares a clipping norm with the rest of the model, it dominates that norm. We found clipping the LeRoPE parameters separately led to stable training.</li>
  <li><strong>No weight decay on \(\alpha\)</strong>, which is less of a prior on RoPE.</li>
  <li><strong>Learn it in log space.</strong> We found this to have better optimization dynamics than directly learning the frequency or learning linear scales.</li>
  <li><strong>Only one set of scales is needed.</strong> Learning separate scales per layer or per head did not improve performance and cost extra parameters.</li>
</ul>

<h2 id="results">Results</h2>

<p>To validate our method we trained a ladder of models ranging from 52M to 2.52B. All models were trained with 20 tokens per parameter on C4 at sequence length 2,048. Here’s validation perplexity:</p>

<div class="table-scroll">

  <table>
    <thead>
      <tr>
        <th>Method</th>
        <th style="text-align: right">52M</th>
        <th style="text-align: right">217M</th>
        <th style="text-align: right">608M</th>
        <th style="text-align: right">1.34B</th>
        <th style="text-align: right">2.52B</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td>RoPE</td>
        <td style="text-align: right">30.2450</td>
        <td style="text-align: right">18.2377</td>
        <td style="text-align: right">13.8795</td>
        <td style="text-align: right">11.5747</td>
        <td style="text-align: right">10.1720</td>
      </tr>
      <tr>
        <td>p-RoPE</td>
        <td style="text-align: right">30.2695</td>
        <td style="text-align: right">18.2269</td>
        <td style="text-align: right">13.8663</td>
        <td style="text-align: right">11.5665</td>
        <td style="text-align: right">10.1659</td>
      </tr>
      <tr>
        <td>LeRoPE</td>
        <td style="text-align: right"><strong>29.9479</strong></td>
        <td style="text-align: right"><strong>18.1335</strong></td>
        <td style="text-align: right"><strong>13.8093</strong></td>
        <td style="text-align: right"><strong>11.5384</strong></td>
        <td style="text-align: right"><strong>10.1387</strong></td>
      </tr>
    </tbody>
  </table>

</div>

<figure class="figure">
  <div class="figure-images">
    <img src="/assets/img/lerope/cm_gap_combined.png" alt="Compute multiplier versus RoPE across scales, and loss gap by token position" loading="lazy" />
  </div>
  <figcaption>Left: compute multiplier against RoPE. Right: loss gap to RoPE by token position. LeRoPE’s gain grows along the context.</figcaption>
</figure>

<p>LeRoPE is 3.4% more compute efficient than RoPE at our largest scale and its gains grow over the context length suggesting better context utilization. In the paper, we also include multi-seed evaluations as well as another scaling ladder trained on StarCoder data. LeRoPE remains a winner in these too.</p>

<h2 id="long-context">Long context</h2>

<p>The real gains from LeRoPE show up in long context tasks. We evaluate our 2.52B models with <a href="https://arxiv.org/abs/2309.00071">YaRN</a> on <a href="https://arxiv.org/abs/2404.06654">RULER</a>’s 8 needle-in-a-haystack tasks and include a 2.52B model trained with a base frequency of 500k.</p>

<div class="table-scroll">

  <table>
    <thead>
      <tr>
        <th>Mean accuracy, 8 NIAH tasks</th>
        <th style="text-align: right">2K</th>
        <th style="text-align: right">4K</th>
        <th style="text-align: right">8K</th>
        <th style="text-align: right">16K</th>
        <th style="text-align: right">32K</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td>RoPE</td>
        <td style="text-align: right">0.509</td>
        <td style="text-align: right">0.457</td>
        <td style="text-align: right">0.351</td>
        <td style="text-align: right">0.245</td>
        <td style="text-align: right">0.152</td>
      </tr>
      <tr>
        <td>RoPE, base 500k</td>
        <td style="text-align: right">0.543</td>
        <td style="text-align: right">0.493</td>
        <td style="text-align: right">0.420</td>
        <td style="text-align: right">0.286</td>
        <td style="text-align: right">0.159</td>
      </tr>
      <tr>
        <td>p-RoPE</td>
        <td style="text-align: right">0.548</td>
        <td style="text-align: right">0.498</td>
        <td style="text-align: right">0.398</td>
        <td style="text-align: right">0.238</td>
        <td style="text-align: right">0.146</td>
      </tr>
      <tr>
        <td>LeRoPE</td>
        <td style="text-align: right"><strong>0.608</strong></td>
        <td style="text-align: right"><strong>0.573</strong></td>
        <td style="text-align: right"><strong>0.511</strong></td>
        <td style="text-align: right"><strong>0.390</strong></td>
        <td style="text-align: right"><strong>0.214</strong></td>
      </tr>
    </tbody>
  </table>

</div>

<figure class="figure">
  <div class="figure-images">
    <img src="/assets/img/lerope/ruler_retrieval.png" alt="RULER retrieval accuracy versus sequence length, and at 16K versus needle depth, for LeRoPE, RoPE, RoPE with base 500k, and p-RoPE" loading="lazy" />
  </div>
  <figcaption>RULER with YaRN at 2.5B. (a) Mean accuracy over the eight needle-in-a-haystack tasks. (b) The same mean at 16K by the depth of the first answer needle; bands are ±1 binomial standard error. The base-500k run (dashed) was trained after the paper.</figcaption>
</figure>

<p>At 16K, LeRoPE’s accuracy is roughly flat across needle depth, while RoPE and p-RoPE collapse for needles near the start of the context. The larger base fixes that collapse but otherwise stays near RoPE.</p>

<h2 id="what-frequencies-do-we-learn">What frequencies do we learn?</h2>

<figure class="figure figure-pair">
  <div class="figure-images">
    <img src="/assets/img/lerope/wavelength_parametric_kladder.png" alt="Learned versus RoPE wavelength per channel for four model sizes" loading="lazy" />
    <img src="/assets/img/lerope/wavelength_parametric_lengths.png" alt="Learned versus RoPE wavelength per channel for three training lengths" loading="lazy" />
  </div>
  <figcaption>Learned wavelength against RoPE’s wavelength for each of the 32 channels. Left: four model sizes. Right: 217M models trained at 2,048, 4,096 and 8,192 tokens. The dashed diagonal is RoPE, horizontal lines mark the training lengths, and circled points are the bands that have the highest absolute logit contribution.</figcaption>
</figure>

<p>The learned pattern is the same across scales and seeds. Fast rotations track standard RoPE (the fastest even speed up a little). Rotations slower than the training context get shut off almost entirely. Where that cutoff falls moves with the training length (right panel).</p>

<p>So is LeRoPE just a learned p-RoPE? Not quite. LeRoPE’s cutoff lands around channel 17 of 32, which is roughly p-RoPE with \(p = 0.5\). But p-RoPE cut there does no better than the standard \(p = 0.75\), so the cutoff alone isn’t where the gain comes from. It comes from how LeRoPE reshapes the channels it keeps, which no choice of \(p\) can express.</p>

<h2 id="one-dominant-positional-band">One dominant positional band</h2>

<p>Since the logit splits by channel, we can look at each channel’s contribution as a function of offset. In LeRoPE models, one channel takes over the positional work almost completely. We call it the dominant positional band:</p>

<figure class="figure">
  <div class="figure-images">
    <img src="/assets/img/lerope/hero_band.png" alt="Per-channel logit contribution versus relative distance in the 2.5B LeRoPE model" loading="lazy" />
  </div>
  <figcaption>Each channel’s contribution to the attention logit against relative distance, in the 2.5B LeRoPE model. Shading is ±1 standard deviation across layers. The other 31 channels stay near zero.</figcaption>
</figure>

<p>It’s channel 17 in our 608M to 2.5B models (16 at 217M). LeRoPE slows it about fivefold, to a wavelength a bit over twice the training length (2.0–2.3× across six runs). So across the training window it traces roughly half a period. Zero at offset 0, most negative mid-window, and back near zero at the edge. It acts as a fairly consistent relative positional bias. The model relies heavily on it. Zeroing this one channel raises the loss by 0.76 nats while no other channel costs more than 0.07. RoPE and p-RoPE don’t show anything close to this level of consolidation.</p>

<p>We worried that a bias like this would make LeRoPE ignore medium- and long-range dependencies. It doesn’t. As the results above show, LeRoPE’s gain over RoPE actually grows with position in the context.</p>

<p>One more question: is the gain about the frequencies LeRoPE finds, or about learning them? It seems to be both. Training a fresh model with LeRoPE’s learned frequencies frozen recovers only about two thirds of the gain (63.6%; p-RoPE recovers about a tenth). The full gain needs the frequencies and the rest of the weights to co-adapt during training.</p>

<h2 id="trying-it-yourself">Trying it yourself</h2>

<p>Here’s a minimal PyTorch version. It uses interleaved pairs, dims \((2m, 2m{+}1)\), as in the paper. If your codebase pairs dims \(m\) and \(m + d_h/2\) instead, keep its convention: only the frequencies change.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">torch</span>
<span class="kn">import</span> <span class="nn">torch.nn</span> <span class="k">as</span> <span class="n">nn</span>


<span class="k">class</span> <span class="nc">LeRoPE</span><span class="p">(</span><span class="n">nn</span><span class="p">.</span><span class="n">Module</span><span class="p">):</span>
    <span class="s">"""RoPE with one learned log-scale per frequency band.

    Create one instance per model and share it across every attention layer.
    """</span>

    <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">head_dim</span><span class="p">:</span> <span class="nb">int</span><span class="p">,</span> <span class="n">base</span><span class="p">:</span> <span class="nb">float</span> <span class="o">=</span> <span class="mf">10_000.0</span><span class="p">):</span>
        <span class="nb">super</span><span class="p">().</span><span class="n">__init__</span><span class="p">()</span>
        <span class="n">m</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">arange</span><span class="p">(</span><span class="n">head_dim</span> <span class="o">//</span> <span class="mi">2</span><span class="p">,</span> <span class="n">dtype</span><span class="o">=</span><span class="n">torch</span><span class="p">.</span><span class="n">float32</span><span class="p">)</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">register_buffer</span><span class="p">(</span><span class="s">"theta"</span><span class="p">,</span> <span class="n">base</span> <span class="o">**</span> <span class="p">(</span><span class="o">-</span><span class="mi">2</span> <span class="o">*</span> <span class="n">m</span> <span class="o">/</span> <span class="n">head_dim</span><span class="p">),</span> <span class="n">persistent</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">log_scale</span> <span class="o">=</span> <span class="n">nn</span><span class="p">.</span><span class="n">Parameter</span><span class="p">(</span><span class="n">torch</span><span class="p">.</span><span class="n">zeros</span><span class="p">(</span><span class="n">head_dim</span> <span class="o">//</span> <span class="mi">2</span><span class="p">))</span>  <span class="c1"># alpha_m; zeros = RoPE
</span>
    <span class="k">def</span> <span class="nf">forward</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">x</span><span class="p">:</span> <span class="n">torch</span><span class="p">.</span><span class="n">Tensor</span><span class="p">,</span> <span class="n">pos</span><span class="p">:</span> <span class="n">torch</span><span class="p">.</span><span class="n">Tensor</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">torch</span><span class="p">.</span><span class="n">Tensor</span><span class="p">:</span>
        <span class="c1"># x: (batch, heads, seq, head_dim); pos: (seq,) token positions
</span>        <span class="n">freqs</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">theta</span> <span class="o">*</span> <span class="bp">self</span><span class="p">.</span><span class="n">log_scale</span><span class="p">.</span><span class="n">exp</span><span class="p">()</span>             <span class="c1"># (head_dim/2,)
</span>        <span class="n">angle</span> <span class="o">=</span> <span class="n">pos</span><span class="p">.</span><span class="nb">float</span><span class="p">()[:,</span> <span class="bp">None</span><span class="p">]</span> <span class="o">*</span> <span class="n">freqs</span><span class="p">[</span><span class="bp">None</span><span class="p">,</span> <span class="p">:]</span>         <span class="c1"># (seq, head_dim/2), fp32
</span>        <span class="n">cos</span><span class="p">,</span> <span class="n">sin</span> <span class="o">=</span> <span class="n">angle</span><span class="p">.</span><span class="n">cos</span><span class="p">(),</span> <span class="n">angle</span><span class="p">.</span><span class="n">sin</span><span class="p">()</span>
        <span class="n">x1</span><span class="p">,</span> <span class="n">x2</span> <span class="o">=</span> <span class="n">x</span><span class="p">[...,</span> <span class="mi">0</span><span class="p">::</span><span class="mi">2</span><span class="p">].</span><span class="nb">float</span><span class="p">(),</span> <span class="n">x</span><span class="p">[...,</span> <span class="mi">1</span><span class="p">::</span><span class="mi">2</span><span class="p">].</span><span class="nb">float</span><span class="p">()</span>   <span class="c1"># band m = dims (2m, 2m+1)
</span>        <span class="n">out</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">stack</span><span class="p">((</span><span class="n">x1</span> <span class="o">*</span> <span class="n">cos</span> <span class="o">-</span> <span class="n">x2</span> <span class="o">*</span> <span class="n">sin</span><span class="p">,</span> <span class="n">x1</span> <span class="o">*</span> <span class="n">sin</span> <span class="o">+</span> <span class="n">x2</span> <span class="o">*</span> <span class="n">cos</span><span class="p">),</span> <span class="n">dim</span><span class="o">=-</span><span class="mi">1</span><span class="p">)</span>
        <span class="k">return</span> <span class="n">out</span><span class="p">.</span><span class="n">flatten</span><span class="p">(</span><span class="o">-</span><span class="mi">2</span><span class="p">).</span><span class="n">type_as</span><span class="p">(</span><span class="n">x</span><span class="p">)</span>
</code></pre></div></div>

<p>Apply it to both queries and keys, as with RoPE. Then set up the optimizer with the clipping and weight-decay notes from above:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">freq_params</span> <span class="o">=</span> <span class="p">[</span><span class="n">model</span><span class="p">.</span><span class="n">rope</span><span class="p">.</span><span class="n">log_scale</span><span class="p">]</span>
<span class="n">other_params</span> <span class="o">=</span> <span class="p">[</span><span class="n">p</span> <span class="k">for</span> <span class="n">p</span> <span class="ow">in</span> <span class="n">model</span><span class="p">.</span><span class="n">parameters</span><span class="p">()</span> <span class="k">if</span> <span class="n">p</span> <span class="ow">is</span> <span class="ow">not</span> <span class="n">model</span><span class="p">.</span><span class="n">rope</span><span class="p">.</span><span class="n">log_scale</span><span class="p">]</span>

<span class="n">opt</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">optim</span><span class="p">.</span><span class="n">AdamW</span><span class="p">(</span>
    <span class="p">[{</span><span class="s">"params"</span><span class="p">:</span> <span class="n">other_params</span><span class="p">,</span> <span class="s">"weight_decay"</span><span class="p">:</span> <span class="n">weight_decay</span><span class="p">},</span>  <span class="c1"># keep your usual no-decay groups
</span>     <span class="p">{</span><span class="s">"params"</span><span class="p">:</span> <span class="n">freq_params</span><span class="p">,</span> <span class="s">"weight_decay"</span><span class="p">:</span> <span class="mf">0.0</span><span class="p">}],</span>
    <span class="n">lr</span><span class="o">=</span><span class="n">lr</span><span class="p">,</span> <span class="n">betas</span><span class="o">=</span><span class="p">(</span><span class="mf">0.9</span><span class="p">,</span> <span class="mf">0.95</span><span class="p">),</span>
<span class="p">)</span>

<span class="c1"># each step, after loss.backward():
</span><span class="n">torch</span><span class="p">.</span><span class="n">nn</span><span class="p">.</span><span class="n">utils</span><span class="p">.</span><span class="n">clip_grad_norm_</span><span class="p">(</span><span class="n">other_params</span><span class="p">,</span> <span class="mf">1.0</span><span class="p">)</span> <span class="c1"># if you're using gradient clipping
</span><span class="n">torch</span><span class="p">.</span><span class="n">nn</span><span class="p">.</span><span class="n">utils</span><span class="p">.</span><span class="n">clip_grad_norm_</span><span class="p">(</span><span class="n">freq_params</span><span class="p">,</span> <span class="mf">1.0</span><span class="p">)</span>  <span class="c1"># its own norm
</span><span class="n">opt</span><span class="p">.</span><span class="n">step</span><span class="p">()</span>
</code></pre></div></div>

<p>Also we compute the angles in fp32, and use a scaling method such as YaRN if you run past the training length. If you’re pretraining with RoPE or p-RoPE, this is essentially free, so give it a try!</p>

<p>This was joint work with Shreyas Kadekodi, Rui Zhu and our advisor Julian McAuley. We’re always happy to chat! The easiest way to reach us is by email (pkarypis [at] ucsd [dot] edu and seobrien [at] ucsd [dot] edu) or on X (<a href="https://x.com/kpetro_research">@kpetro_research</a> and <a href="https://x.com/seano_research">@seano_research</a>).</p>

<div class="language-bibtex highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">@article</span><span class="p">{</span><span class="nl">karypis2026lerope</span><span class="p">,</span>
  <span class="na">title</span>   <span class="p">=</span> <span class="s">{LeRoPE: Learnable RoPE Frequencies Improve Language Modeling}</span><span class="p">,</span>
  <span class="na">author</span>  <span class="p">=</span> <span class="s">{Karypis, Petros and O'Brien, Sean and Kadekodi, Shreyas and Zhu, Rui and McAuley, Julian}</span><span class="p">,</span>
  <span class="na">journal</span> <span class="p">=</span> <span class="s">{arXiv preprint arXiv:2607.10134}</span><span class="p">,</span>
  <span class="na">year</span>    <span class="p">=</span> <span class="s">{2026}</span>
<span class="p">}</span>
</code></pre></div></div>]]></content><author><name>Petros Karypis</name></author><summary type="html"><![CDATA[LeRoPE learns RoPE's rotation frequencies during pretraining and improves long-context performance. It's now used in Mistral Large 4.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://petroskarypis.github.io/assets/img/lerope/cm_gap_combined.png" /><media:content medium="image" url="https://petroskarypis.github.io/assets/img/lerope/cm_gap_combined.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>