はじめに
2026年1月に、YOLOの最新モデルであるYOLO26がリリースされました。開発元は、YOLOv5やYOLOv8、YOLO11などの人気モデルを手がけてきたUltralyticsです。YOLO26は2025年秋の時点で開発が進んでいることが告知されていましたが、しばらくはコードが公開されていませんでした。そのため、今回のリリースは満を持しての登場と言えます。
公式ドキュメントには多数の改良点が挙げられていますが、UltralyticsはYOLOv5/v8/11に続き、YOLO26についても2026年1月時点では改良点を査読論文として体系化して公開していません(arXivに非公式の論文は存在するものの、詳細な技術情報は含まれていません)。
そのため本記事では、公式ドキュメントの主張を出発点にしつつ、実際のソースコードを根拠に、YOLO26の変更点を“実装ベース”で読み解いていきます。
Ultralyticsは継続的に修正・改良が行われているため、ソースコードの仕様や挙動は今後のアップデートで変わる可能性があります。
本記事は執筆時点の Ultralytics v8.4.7(Pythonパッケージ ultralytics)を前提に、該当実装を読み解きます。
YOLOは「v13」から急に「YOLO26」という表記になりました。仮に年号(2026→26)に寄せた命名だとすると分かりやすい一方で、同じ年に別の開発元が同じ番号を使うと紛らわしくなりそうです。今後このナンバリングが継続するのか、別のルールに落ち着くのかが気になります。
前提知識
本記事は、Python(PyTorch)の基礎(モデル定義・学習の流れ)を理解している読者を対象とします。また、YOLOのアーキテクチャや学習戦略、これまでの進化の流れ(特にYOLO11)について、ある程度の知識があることを前提とします。
従来のYOLOの課題
YOLO26の改良点を見る前に、従来のYOLO系モデルが抱えていた代表的な課題を整理します。
ここでは特に、YOLO26の変更点と直結する 「後処理(NMS)」 と 「回帰表現(DFL)」 の2点に絞ります。
課題1:NMSによる推論レイテンシの増加
多くのYOLO系モデルでは、モデルが出力した多数の候補ボックスから最終結果を選別するために、推論の最後にNMS(Non-Maximum Suppression)を行います。
NMSは精度面では有効ですが、推論パイプラインに「モデル推論とは別の処理」を追加するため、実行環境によってはレイテンシの増加要因になります。
近年はNMSを前提としないend-to-endな検出設計も登場しており、NMSの存在は相対的にYOLO側のボトルネックとして意識されやすくなっています。
また、後処理が入ることで推論が「モデルだけで完結しない」構造になり、エッジデバイスやリアルタイム処理では運用上の制約になりがちです。
最適化の余地はあるものの、NMSの存在自体がレイテンシと実装複雑性の“固定コスト”として残ります。
課題2:DFLによるエクスポートの複雑化
近年のYOLOでは、バウンディングボックス回帰にDFL(Distribution Focal Loss)を用いる設計が広く採用されてきました。
ただしDFLは「回帰を分布として表現し、そこから値を復元する」処理を含むため、推論側でも追加の計算(分布の復元に相当する処理)が必要になります。
この追加処理は、PyTorch上では自然に扱える一方で、ONNXやTensorRTなどへのエクスポートや最適化の段階で、演算や実装の都合が絡んで手間が増える場合があります。
つまり、DFLは精度に寄与し得る一方で、デプロイ(特にエクスポートと最適化)の観点では“構造が複雑になりやすい”という側面を持ちます。
以上の2点は、どちらも「高精度・高速・デプロイ容易性」を同時に満たしたいときにボトルネックになりがちな部分です。
YOLO26の改良点概要
従来の課題に対して、YOLO26では主に次の4点が見直されています。
- NMSを不要にする推論設計(NMS-free)
- DFLを使わない回帰設計
- ProgLoss / STAL の導入
- MuSGDオプティマイザの導入
これらの改良により、推論速度と検出精度のトレードオフが大幅に改善されました。下図に示すように、YOLO26は従来のYOLOシリーズや他の最新検出器と比較して、同等以上の精度を維持しながら、より高速な推論を実現しています。
以下では、各改良点の詳細をソースコードをもとに解説します。重要な点として、YOLO26は学習戦略を大きく刷新した一方で、アーキテクチャはYOLO11からほとんど変更されておらず、C3k2モジュールを主要な構成要素とした設計が継続されています。ただし、細かな改良点は存在するため、最後にそれらについても触れます。
改良点①:NMSを不要にする推論設計(NMS-Free)
YOLO26では、YOLOv10で提案されたdual-head構造によるNMS-Free設計を採用しました。この設計の核心は、2つの検出ヘッドで異なるラベル割り当て戦略を使い分けることにあります。
Dual-Head構造の仕組み
学習時には、検出ヘッドを2つ用意します。1つ目のone-to-many headでは、従来のYOLOと同様にTAL(Task Aligned Learning)によるラベル割り当てを行います。TALは、1つのGround Truthに対して複数の予測ボックス(最大10個程度)を正例として割り当てる手法です。これにより、様々なスケールや位置の予測ボックスが学習に貢献でき、モデルの表現力が向上します。
一方、2つ目のone-to-one headでは、1つのGround Truthに対して最も適合度の高い予測ボックス1つのみを正例として割り当てます。この制約により、one-to-one headは各物体に対して単一の検出結果のみを出力するよう学習されます。
推論時には、one-to-one headのみを使用します。このヘッドは学習段階で既に「1物体に対して1検出」という制約を内部化しているため、出力結果に重複が生じません。したがって、NMSによる後処理が不要になります。
この設計により、推論パイプラインが簡潔になるだけでなく、NMSのハイパーパラメータ(IoU閾値など)に依存しない、より安定した検出結果が得られます。
ソースコードでの実装
では、この設計がYOLO26でどのように実装されているかを見ていきます。実装は大きく3つの部分に分かれています:
- Detectクラス:2つのヘッドの定義と推論時の切り替え
- E2ELossクラス:2つのヘッドに対する異なる損失関数の定義
- TaskAlignedAssignerクラス:具体的なラベル割り当ての実行
1. Detectクラス:Dual-Headの定義と使い分け
初期化:2つのヘッドの作成
まず、Detectクラスの__init__メソッドで、2つのヘッドが作成されます。
class Detect(nn.Module):
def __init__(self, nc: int = 80, reg_max=16, end2end=False, ch: tuple = ()):
super().__init__()
# ... 省略 ...
# One-to-many head(従来のYOLOと同じヘッド)
self.cv2 = nn.ModuleList(...) # バウンディングボックス回帰用
self.cv3 = nn.ModuleList(...) # クラス分類用
# One-to-one head(end2end=Trueの場合のみ作成)
if end2end:
self.one2one_cv2 = copy.deepcopy(self.cv2)
self.one2one_cv3 = copy.deepcopy(self.cv3)
ここでのポイント:
-
cv2:バウンディングボックスの座標を予測するヘッド -
cv3:クラス分類スコアを予測するヘッド -
end2end=Trueの場合のみ、copy.deepcopyで同じ構造のone-to-one headが追加作成される - 2つのヘッドのアーキテクチャは完全に同一
forward:学習時と推論時の処理の切り替え
forwardメソッドで、学習時と推論時の処理が分岐されます。
def forward(self, x: list[torch.Tensor]) -> ...:
# ① One-to-many headで予測を生成
preds = self.forward_head(x, **self.one2many)
# ② End2endモードの場合、one-to-one headでも予測を生成
if self.end2end:
x_detach = [xi.detach() for xi in x] # 勾配を切断
one2one = self.forward_head(x_detach, **self.one2one)
preds = {"one2many": preds, "one2one": one2one}
# ③ 学習時:両方の予測を損失計算のために返す
if self.training:
return preds
# ④ 推論時:one-to-one headの予測のみを使用
y = self._inference(preds["one2one"] if self.end2end else preds)
if self.end2end:
y = self.postprocess(y.permute(0, 2, 1))
return y if self.export else (y, preds)
処理の流れ:
学習時(self.training=True):
- One-to-many headで予測を生成(複数の正例で学習)
- One-to-one headでも予測を生成(1つの正例のみで学習)
- 注目:
detach()により、one-to-one headの損失はバックボーンとネックに伝播しない - バックボーンの学習はone-to-many headが担当
- 注目:
- 両方の予測を返し、それぞれで定義した損失関数で学習
推論時(self.training=False):
- One-to-one headの予測のみを使用
- このヘッドは「1物体1検出」を学習済みのため、NMS不要
2. E2ELossクラス:異なるラベル割り当て戦略の定義
2つのヘッドは同じ構造ですが、異なるラベル割り当て戦略で学習されます。その違いはE2ELossクラスで定義されます。
class E2ELoss:
def __init__(self, model, loss_fn=v8DetectionLoss):
# One-to-many head用の損失関数
self.one2many = loss_fn(model, tal_topk=10)
# One-to-one head用の損失関数
self.one2one = loss_fn(model, tal_topk=7, tal_topk2=1)
パラメータの違い:
-
One-to-many:
tal_topk=10のみ → 最大10個の正例を割り当て -
One-to-one:
tal_topk=7, tal_topk2=1→ 7個の候補から1個のみ選択
このtal_topk2=1が、one-to-one割り当てを実現する鍵です。
3. TaskAlignedAssigner:topk2による絞り込みの実装
TALの基本的な動作
TAL(Task Aligned Learning)は、各Ground Truthに対して適切なアンカーを正例として選択する手法です。基本的な流れは以下の通りです:
- 各アンカーに対してalignment metric(分類スコアとIoUの積)を計算
- Alignment metricが高い上位
topk個のアンカーを候補として選択
従来のYOLOでは、このtopk個すべてが正例として学習されます。
topk2の導入:1つに絞り込む
YOLO26のone-to-one headではtopk2パラメータが導入されました。これにより、ラベル割り当てが2段階で行われます。
One-to-many headの場合(topk=10のみ):
各Ground Truthに対して、上位10個のアンカーすべてが正例として学習されます。推論時には、これら10個のアンカーが全て検出結果を出力するため、同じ物体に対して重複した検出が発生します。このため、NMSによる後処理が必要になります。
One-to-one headの場合(topk=7, topk2=1):
各Ground Truthに対して、まず上位7個のアンカーを候補として確保します。その後、衝突解決などを経て、最終的に1個のみを正例として採用します。推論時には、1個のアンカーのみが検出結果を出力するため、重複が発生せず、NMSが不要になります。
なぜ2段階(topk→topk2)が必要なのか
最終的に1つだけ必要なら、最初からtopk=1にすればいいのでは?という疑問が生じます。しかし、topkとtopk2は異なる目的を持っています。
各パラメータの役割:
- topk: 衝突が起きて正例が0になるリスクを下げるために、各Ground Truthに複数の候補を確保する
- topk2: 衝突解決を経た後、最終的に何個残すかを決める
topk=1の問題:衝突時に正例が消失する
複数のGround Truthが近接している場合、同じアンカーが複数のGround Truthの候補になることがあります(衝突)。この衝突は、IoUが最も高いGround Truth1つに割り当てることで解決されますが、topk=1の場合、負けた側のGround Truthは正例を失います。
例えば、アンカー100がGround Truth AとGround Truth Bの両方の候補に選ばれた状況を考えます。topk=1の場合、両方のGround Truthにとって候補はアンカー100のみです。衝突解決でIoUが高いGround Truth Bにアンカー100が割り当てられると、Ground Truth Aは正例がゼロになり、学習信号を受け取れません。
topk=7, topk2=1の解決策:冗長性による衝突対策
topk=7で複数の候補を確保しておけば、1つが衝突で失われても他の候補が残ります。
同じ状況でtopk=7の場合を見てみましょう。Ground Truth Aには候補としてアンカー100, 105, 110, 115, 120, 125, 130があり、Ground Truth Bには100, 95, 90, 85, 80, 75, 70があります。アンカー100で衝突が発生し、Ground Truth Bに割り当てられても、Ground Truth Aにはまだ105, 110, 115, 120, 125, 130が残ります。その後、topk2=1で絞り込みを行い、残った候補の中からalignment metricが最も高いもの(例:アンカー105)を選択します。これにより、Ground Truth Aも正例を持つことができます。
つまり、topk=7は衝突による正例消失を防ぐバッファの役割を果たし、topk2=1はバッファの中から最良の1つを選ぶ役割を果たします。
実装:select_highest_overlapsメソッド
この2段階処理は、TaskAlignedAssignerのselect_highest_overlapsメソッドで実装されています。
def select_highest_overlaps(self, mask_pos, overlaps, n_max_boxes, align_metric):
"""アンカーの衝突を解決し、topk2で絞り込む
入力:各Ground Truthにtopk個の候補が割り当てられた状態
出力:衝突解決後、topk2個に絞り込まれた状態
"""
# ========== Step 1: 衝突解決 ==========
# 1つのアンカーが複数のGround Truthに候補として選ばれている場合、
# IoUが最も高いGround Truth1つだけに割り当てる
fg_mask = mask_pos.sum(-2) # 各アンカーに割り当てられたGT数を集計
if fg_mask.max() > 1: # 衝突がある場合
# どのアンカーが複数のGTに割り当てられているかを特定
mask_multi_gts = (fg_mask.unsqueeze(1) > 1).expand(-1, n_max_boxes, -1)
# 各アンカーについて、IoUが最も高いGTを選択
max_overlaps_idx = overlaps.argmax(1)
is_max_overlaps = torch.zeros(mask_pos.shape, dtype=mask_pos.dtype, device=mask_pos.device)
is_max_overlaps.scatter_(1, max_overlaps_idx.unsqueeze(1), 1)
# 衝突しているアンカーについては、IoU最大のGTのみに割り当て
mask_pos = torch.where(mask_multi_gts, is_max_overlaps, mask_pos).float()
fg_mask = mask_pos.sum(-2)
# ========== Step 2: topk2による絞り込み ==========
# 衝突解決後に残った候補から、alignment metricの上位topk2個のみを選択
if self.topk2 != self.topk:
# 現在残っている候補に対してalignment metricを適用
align_metric = align_metric * mask_pos
# Alignment metricの上位topk2個のインデックスを取得
max_overlaps_idx = torch.topk(align_metric, self.topk2, dim=-1, largest=True).indices
# topk2個のみをTrueとする新しいマスクを作成
topk_idx = torch.zeros(mask_pos.shape, dtype=mask_pos.dtype, device=mask_pos.device)
topk_idx.scatter_(-1, max_overlaps_idx, 1.0)
# マスクを更新(topk2個のみが残る)
mask_pos *= topk_idx
fg_mask = mask_pos.sum(-2)
target_gt_idx = mask_pos.argmax(-2)
return target_gt_idx, fg_mask, mask_pos
処理の流れ:
Step 1: 衝突解決
1つのアンカーが複数のGround Truthの候補になっている場合、IoUが最も高いGround Truth1つだけに割り当てます。負けたGround Truthはそのアンカーを失いますが、topkで複数候補を確保しておくことで、正例がゼロになるリスクを下げます。
Step 2: topk2による絞り込み
衝突解決後に残った候補(各GTに0〜7個)の中から、alignment metricの上位topk2個を選択します。topk2=1の場合、各Ground Truthに最大1個の正例が残ります。
この2段階(衝突解決+topk2絞り込み)は、学習時の割り当てを可能な限り一対一に寄せるための工夫です。YOLO26のone-to-one headは推論時にNMSを必要としない設計ですが、こうした学習側の割り当ても、重複を抑える方向に働きます。
改良点②:DFLを使わない回帰設計
DFLとは何か
近年のYOLOでは、バウンディングボックスの位置予測にDFL(Distribution Focal Loss)を用いる設計が採用されていました。
DFL登場以前の物体検出では、アンカーポイントから物体の各辺(左・上・右・下)までの距離を、単一の連続値として直接予測していました。しかし、DFLでは異なるアプローチを取ります。距離を離散的な「ビン(カテゴリ)」に分割し、各ビンに対する確率分布として予測します。そして、この確率分布から期待値(重み付き平均)を計算することで、最終的な距離を復元します。
DFLの仕組み(YOLO11の場合):
具体的に、YOLO11で使用されているreg_max=16の設定を例に見てみましょう。この場合、距離の範囲[0, 16]を16個のビン(0, 1, 2, 3, ..., 15)に分割します。
各辺(左・上・右・下)について、この16個のビンに対する確率を予測します。左辺なら[p0, p1, p2, ..., p15]、上辺も[p0, p1, p2, ..., p15]という形で、4辺それぞれが16個の確率値を出力します。最終的な距離は、これらの確率を重みとした期待値Σ(i × pi)として計算されます。
この方式では、4辺それぞれが16個の値を出力するため、出力チャネル数は4 × 16 = 64チャネルになります。
YOLO26の変更:DFLの撤廃
YOLO26では、この分布回帰(DFL)を撤廃し、距離を直接回帰する設計に戻しました。具体的にはreg_max=1に設定することで、各辺について単一の値のみを出力します。
つまり、左辺・上辺・右辺・下辺それぞれについて1つの距離値を直接予測します。出力チャネル数は4辺 × 1 = 4チャネルとなり、これによりヘッドの出力が大幅に削減され、モデル構造とエクスポートがシンプルになります。
設定の違い
この挙動は、モデル設定ファイル(ultralytics/cfg/models)のreg_maxパラメータで制御されます。
YOLO11:
デフォルト値reg_max=16が使用されます。
YOLO26(yolo26.yaml):
end2end: True # whether to use end-to-end mode
reg_max: 1 # DFL bins(=1なら分布回帰をしない)
ソースコードでの実装
Detectクラス:出力の生成
Detectクラスでは、reg_maxに応じて出力チャネル数とDFLレイヤーの有無が切り替わります。
class Detect(nn.Module):
def __init__(self, nc: int = 80, reg_max=16, end2end=False, ch: tuple = ()):
super().__init__()
self.nc = nc
self.reg_max = reg_max
self.no = nc + self.reg_max * 4 # 出力数:クラス + (4辺 × reg_max)
# バウンディングボックス回帰ヘッド:4 × reg_maxチャネルを出力
self.cv2 = nn.ModuleList(
nn.Sequential(Conv(x, c2, 3), Conv(c2, c2, 3), nn.Conv2d(c2, 4 * self.reg_max, 1))
for x in ch
)
# reg_max > 1 の場合のみDFLレイヤーを使用
self.dfl = DFL(self.reg_max) if self.reg_max > 1 else nn.Identity()
YOLO11(reg_max=16):
-
cv2の出力:64チャネル(各辺16ビンの確率分布) -
self.dfl = DFL(16):確率分布から距離を復元
YOLO26(reg_max=1):
-
cv2の出力:4チャネル(各辺1値の直接回帰) -
self.dfl = nn.Identity():何もせず素通し
BboxLoss:損失関数の違い
reg_maxの値は、損失関数にも影響します。
class BboxLoss(nn.Module):
def __init__(self, reg_max: int = 16):
super().__init__()
# reg_max > 1 の場合のみDFL損失を使用
self.dfl_loss = DFLoss(reg_max) if reg_max > 1 else None
def forward(self, pred_dist, pred_bboxes, anchor_points, target_bboxes,
target_scores, target_scores_sum, fg_mask, imgsz, stride):
# IoU損失(共通)
weight = target_scores.sum(-1)[fg_mask].unsqueeze(-1)
iou = bbox_iou(pred_bboxes[fg_mask], target_bboxes[fg_mask], xywh=False, CIoU=True)
loss_iou = ((1.0 - iou) * weight).sum() / target_scores_sum
# 距離損失(reg_maxに応じて切り替え)
if self.dfl_loss:
# YOLO11: DFL損失(分布とターゲット距離の差)
target_ltrb = bbox2dist(anchor_points, target_bboxes, self.dfl_loss.reg_max - 1)
loss_dfl = self.dfl_loss(pred_dist[fg_mask].view(-1, self.dfl_loss.reg_max),
target_ltrb[fg_mask]) * weight
loss_dfl = loss_dfl.sum() / target_scores_sum
else:
# YOLO26: L1損失(予測距離とターゲット距離の差)
target_ltrb = bbox2dist(anchor_points, target_bboxes)
# 画像サイズで正規化
target_ltrb = target_ltrb * stride
target_ltrb[..., 0::2] /= imgsz[1] # x方向
target_ltrb[..., 1::2] /= imgsz[0] # y方向
pred_dist = pred_dist * stride
pred_dist[..., 0::2] /= imgsz[1]
pred_dist[..., 1::2] /= imgsz[0]
loss_dfl = (
F.l1_loss(pred_dist[fg_mask], target_ltrb[fg_mask], reduction="none")
.mean(-1, keepdim=True) * weight
)
loss_dfl = loss_dfl.sum() / target_scores_sum
return loss_iou, loss_dfl
損失関数の違い:
-
YOLO11(
reg_max=16): DFL損失を使用。予測した確率分布とターゲット距離(離散化されたビン)の間の損失を計算し、確率分布の形状を学習する -
YOLO26(
reg_max=1): L1損失を使用。予測距離とターゲット距離の絶対値差を計算する単純な回帰問題として扱う。予測値とターゲット値を画像サイズで正規化してから損失計算を行う
YOLO11とYOLO26の比較:DFL撤廃による変更
| YOLO11 | YOLO26 | |
|---|---|---|
| reg_max | 16 | 1 |
| 予測方式 | 確率分布(ビン) | 直接回帰 |
| 出力チャネル数(回帰) | 64 | 4 |
| DFLレイヤー | あり | なし(Identity) |
| 回帰の追加損失項 | DFL損失 | L1損失 |
この変更により、YOLO26では:
- 検出ヘッドの回帰出力が大幅に削減される(64→4チャネル)
- 分布復元(DFL)に伴う処理が不要になる
- 回帰の損失計算がシンプルになる(DFL項 → L1項)
結果として、モデルの見通しやエクスポートの扱いやすさが改善し、少なくとも検出ヘッド周りの計算は軽くなる方向に働きます。
改良点③:ProgLoss/STALの導入
YOLO26では、学習を安定化させるために2つの新しい手法が導入されました:Progressive Loss Balancing(ProgLoss)とSmall-Target-Aware Label Assignment(STAL)です。
Progressive Loss Balancing(ProgLoss)
改良点①で説明したように、YOLO26はone2many headとone2one headの2つのヘッドを持ちます。ProgLossは、この2つのヘッドの損失の重み付けを、学習の進行に応じて動的に変化させる手法です。
基本的な考え方
学習初期は、複数の正例で豊富な学習信号を提供するone2many headを重視し、学習が進むにつれて、推論時に使用するone2one headを重視するように重みを変化させます。
学習初期:
- one2many headの重み = 0.8(高い)
- one2one headの重み = 0.2(低い)
- 幅広い学習信号でモデルを育てる
学習終盤:
- one2many headの重み = 0.1(低い)
- one2one headの重み = 0.9(高い)
- 推論時に使うヘッドを重点的に学習
ソースコードでの実装
ProgLossはE2ELossクラスで実装されています。
class E2ELoss:
def __init__(self, model, loss_fn=v8DetectionLoss):
# 2つの損失関数を初期化
self.one2many = loss_fn(model, tal_topk=10)
self.one2one = loss_fn(model, tal_topk=7, tal_topk2=1)
self.updates = 0
self.total = 1.0
# 重みの初期化
self.o2m = 0.8 # one2manyの初期重み
self.o2o = self.total - self.o2m # one2oneの初期重み(= 0.2)
self.o2m_copy = self.o2m # 初期値を保存(decay計算用)
# 最終的な重み
self.final_o2m = 0.1 # one2manyの最終重み
損失の計算:
def __call__(self, preds, batch):
# 予測をパース
preds = self.one2many.parse_output(preds)
one2many, one2one = preds["one2many"], preds["one2one"]
# 各ヘッドの損失を計算
loss_one2many = self.one2many.loss(one2many, batch)
loss_one2one = self.one2one.loss(one2one, batch)
# 重み付けして合計
return loss_one2many[0] * self.o2m + loss_one2one[0] * self.o2o, loss_one2one[1]
重みの更新:
エポックごとにupdate()メソッドが呼ばれ、重みが更新されます。
def update(self):
self.updates += 1
self.o2m = self.decay(self.updates) # one2manyの重みを減衰
self.o2o = max(self.total - self.o2m, 0) # one2oneの重みを増加
def decay(self, x):
# 線形減衰:初期値(0.8) → 最終値(0.1)
progress = x / max(self.one2one.hyp.epochs - 1, 1) # 学習進捗(0〜1)
decay_amount = (self.o2m_copy - self.final_o2m) # 減衰幅(0.7)
return max(1 - progress, 0) * decay_amount + self.final_o2m
decay()メソッドは線形減衰を実装しています。学習進捗を0〜1の範囲で計算し、それに応じてone2manyの重みを初期値0.8から最終値0.1まで線形的に減少させます。
例えば、学習開始時(progress = 0.0)では decay = 1.0 × (0.8 - 0.1) + 0.1 = 0.8、学習中盤(progress = 0.5)では decay = 0.5 × (0.8 - 0.1) + 0.1 = 0.45、最終エポック(progress = 1.0)では decay = 0.0 × (0.8 - 0.1) + 0.1 = 0.1 となります。
減衰スケジュール:
学習の進行に伴う重みの変化は以下のようになります:
- エポック 0: o2m = 0.8, o2o = 0.2
- エポック 25%: o2m ≈ 0.625, o2o ≈ 0.375
- エポック 50%: o2m ≈ 0.45, o2o ≈ 0.55
- エポック 75%: o2m ≈ 0.275, o2o ≈ 0.725
- 最終エポック: o2m = 0.1, o2o = 0.9
この仕組みにより、学習初期はone2many headで広範な学習を行い、学習が進むにつれてone2one headに焦点を移すことで、推論時のパフォーマンスを最適化します。
Small-Target-Aware Label Assignment(STAL)
YOLO26では、小物体の検出性能を向上させるため、TALを改良したSTAL(Small-Target-Aware Label Assignment)が導入されました。
問題点:小物体が学習から除外される
TAL(Task Aligned Learning)では、アンカーポイントがGround Truthの内部に含まれる場合のみ、そのアンカーが正例候補として選ばれます。しかし、小さな物体の場合、この条件を満たすアンカーが1つも存在しない状況が発生します。
なぜ小物体で割り当てがゼロになるのか:
YOLOでは、候補となるアンカー中心(アンカーポイント)が stride 間隔の格子状に並びます。たとえば最小strideが 8 のとき、アンカー中心は入力座標系上で「8刻み」の点列として配置されます。
このとき、Ground Truth(GT)の幅または高さが stride より小さいと、GTが覆う領域が非常に狭くなります。その結果、配置の都合でGT内部にアンカー中心が1つも入らないケースが起こり得ます。内部に入るアンカー中心が0個になると、そのGTは正例候補を持てず、学習上は「割り当てがゼロ」になってしまいます。
イメージとしては、8ピクセル間隔で点が並ぶ格子の上に、6×6の小さな箱(GT)を置いたとき、箱の位置によっては格子点を1つも含まない──という状況です。
このような「候補ゼロ」は、特に 640×640 入力で 一辺が8ピクセル前後より小さい物体で起こりやすく、小物体が学習で無視されやすくなる要因になります。
STALの解決策:GTサイズの底上げ
STALは、8ピクセル未満の小さなGround Truthに対して、そのサイズを一時的に拡大することで、小物体が割り当てから外れにくい状態を作ります。
基本的な戦略:
幅または高さが最小stride(例:8px)未満のGround Truth(GT)を検出し、その小さい辺を次のstride相当(典型例:16px)まで一時的に底上げします。これにより、stride間隔で配置されたアンカー中心(格子点)がGT内部に入りやすくなり、小物体で正例候補が0になってしまう状況を避けやすくなります。
-
拡大前(8ピクセル未満の小物体):GTが小さすぎると、配置によってはGT内部にアンカー中心が入らず「候補0」になり得ます。
-
拡大後(小さい辺を16ピクセルに底上げ):中心はそのままで幅/高さだけを広げるイメージです。覆う領域が増えるため、GT内部にアンカー中心が入る可能性が高まり、複数候補が残りやすくなります。
ソースコードでの実装
STALはTaskAlignedAssignerのselect_candidates_in_gtsメソッドで実装されています。
def select_candidates_in_gts(self, xy_centers, gt_bboxes, mask_gt, eps=1e-9):
"""GTの内部にあるアンカーを正例候補として選択"""
# xyxy形式からxywh形式に変換
gt_bboxes_xywh = xyxy2xywh(gt_bboxes)
# 幅または高さが最小stride未満のGTを検出
wh_mask = gt_bboxes_xywh[..., 2:] < self.stride[0] # self.stride[0] = 8
# 次のstride値を取得(通常16)
stride_val = torch.tensor(self.stride[1], dtype=gt_bboxes_xywh.dtype,
device=gt_bboxes_xywh.device)
# 小さいGTのw/hをstride_valに置き換え
gt_bboxes_xywh[..., 2:] = torch.where(
(wh_mask * mask_gt).bool(), # 条件:w < 8 または h < 8
stride_val, # True: 16に置き換え
gt_bboxes_xywh[..., 2:] # False: 元の値を維持
)
# xywh形式からxyxy形式に戻す
gt_bboxes = xywh2xyxy(gt_bboxes_xywh)
# アンカーがGT内部にあるかを判定
n_anchors = xy_centers.shape[0]
bs, n_boxes, _ = gt_bboxes.shape
lt, rb = gt_bboxes.view(-1, 1, 4).chunk(2, 2) # left-top, right-bottom
bbox_deltas = torch.cat((xy_centers[None] - lt, rb - xy_centers[None]), dim=2).view(bs, n_boxes, n_anchors, -1)
# 全ての辺からの距離が正(=内部)ならTrue
return bbox_deltas.amin(3).gt_(eps)
処理の流れ:
-
小物体の検出:
wh_mask = gt_bboxes_xywh[..., 2:] < self.stride[0]で、幅または高さが8ピクセル未満のGTを検出 -
サイズの拡大:
torch.where()を使い、条件を満たすGTの幅と高さを16ピクセルに置き換え -
候補選択:拡大後のGTを使って、内部に含まれるアンカーを正例候補として選択
この処理により、8ピクセル未満の小物体でもアンカー中心がGT内部に入りやすくなり、正例候補が0になってしまう状況を緩和できます。結果として、小物体にも学習信号が入りやすくなります。
なお、このサイズの底上げはラベル割り当て(正例候補の選定)に用いる一時的な操作であり、推論時に出力するボックスを直接拡するわけではありません。
改良点④:MuSGDオプティマイザの導入
YOLO26では、学習の安定化と収束の高速化を目的として、新しい最適化手法が導入されました。それがMuSGDです。
MuSGDは、MuonとSGDという2つの異なる最適化手法を組み合わせたハイブリッド型のオプティマイザです。Muonは最近提案された新しい手法で、SGDは従来から広く使われている標準的な手法です。YOLO26ではこの両者の長所を活かすため、同一の更新ステップ内で両方を適用します。
以下では、まずSGDとMuonそれぞれの特徴を説明した後、両者をどのように組み合わせているかを見ていきます。
SGDの基本動作
SGDは勾配の逆方向に重みを更新する基本的な最適化手法です。実装では通常、モメンタムとweight decayを組み合わせて使用します。
- モメンタム:過去の勾配情報を保持し、更新方向を安定させる。勾配のノイズによる振動を抑制する効果がある
- weight decay:重みの二乗ノルムにペナルティを課すことで、過学習を抑制し汎化性能を向上させる
YOLO系モデルでは、この「SGD + モメンタム + weight decay」の組み合わせが標準的に使われてきました。SGDは長年の実績があり、安定した収束が期待できる一方で、設定(学習率・モメンタム等)によって学習が停滞したり不安定になることがあるという課題もあります。
Muonとは
Muonは、行列の直交化を利用した最適化手法で、近年は大規模モデル学習の文脈でも注目されています。その核心的なアイデアは、フィルタ間で更新方向が揃いすぎることを防ぐことにあります。
問題:フィルタの役割が重複する
通常のSGD更新では、各フィルタが独立に勾配方向へ更新されます。しかし、似た勾配を持つフィルタ同士は類似した方向へ更新されるため、学習が進むにつれてフィルタ同士の役割が重複し、ネットワークの表現力が十分に活用されない可能性があります。
Muonの解決策:更新量の整形
Muonは、畳み込み層の重みテンソル[out_ch, in_ch, kH, kW]を2次元行列に変形し、その更新量に特殊な処理を施します:
def muon_update(grad: torch.Tensor, momentum: torch.Tensor, beta: float = 0.95, nesterov: bool = True) -> torch.Tensor:
"""Compute Muon optimizer update with momentum and orthogonalization."""
# モメンタムバッファの更新:momentum = beta * momentum + (1-beta) * grad
momentum.lerp_(grad, 1 - beta)
# Nesterov momentum
update = grad.lerp(momentum, beta) if nesterov else momentum
# 4D tensor(畳み込みフィルタ)の場合は2Dに変形
if update.ndim == 4:
update = update.view(len(update), -1)
# Newton-Schulz反復で更新量を直交化
update = zeropower_via_newtonschulz5(update)
# パラメータの次元に応じてスケーリング
update *= max(1, grad.size(-2) / grad.size(-1)) ** 0.5
return update
4D tensorの場合、view(len(update), -1)により以下のように変形されます:
- 各行:1つの出力フィルタ全体(out_chごと)
- 各列:フィルタ内の個別の重み要素(in_ch × kH × kW個)
この2次元行列に対して、Newton–Schulz反復という処理を適用することで、更新量を整形します。この処理は更新量を“より直交に近い形”へ整形し、フィルタ同士が同じ方向へ更新されすぎるのを抑える方向に働きます。
Newton–Schulz反復による直交化
この2次元行列に対して適用されるzeropower_via_newtonschulz5()は、フィルタ間の更新方向が似すぎないように調整する処理です。
def zeropower_via_newtonschulz5(G: torch.Tensor, eps: float = 1e-7) -> torch.Tensor:
"""Compute the zeroth power / orthogonalization of matrix G using Newton-Schulz iteration."""
assert len(G.shape) == 2
X = G.bfloat16()
X /= X.norm() + eps # 最大特異値が1以下になるように正規化
# 効率化のため、行数 > 列数の場合は転置
if G.size(0) > G.size(1):
X = X.T
# 5回のNewton-Schulz反復(係数は固定)
for a, b, c in [
(3.4445, -4.7750, 2.0315),
(3.4445, -4.7750, 2.0315),
(3.4445, -4.7750, 2.0315),
(3.4445, -4.7750, 2.0315),
(3.4445, -4.7750, 2.0315),
]:
A = X @ X.T
B = b * A + c * A @ A
X = a * X + B @ X
if G.size(0) > G.size(1):
X = X.T
return X.to(G.dtype)
この処理を簡潔に言えば:
- 入力:更新量を並べた行列(各行が1つのフィルタの更新方向)
- 処理:5回の反復計算で、行同士の相関を減らすように調整
- 出力:フィルタ間でより独立した更新方向
数学的には「直交化」と呼ばれる操作ですが、完全に直交させるわけではなく、反復回数を5回に限定して計算コストを抑えています。係数(3.4445, -4.7750, 2.0315)は経験的に最適化された値で、この値の導出過程を理解する必要はありません。
重要なのは、この処理により、似た勾配を持つフィルタ同士が同じ方向へ更新されにくくなるという点です。
MuSGD:MuonとSGDのハイブリッド
ここまでで、SGDとMuonという2つの異なる最適化手法を見てきました。それぞれに長所と短所があります:
- SGD:安定した収束、実績のある性能、weight decayによる正則化が可能。ただし局所最適解に陥りやすい
- Muon:フィルタの多様化を促進し表現力を高める。ただし計算コストがかかり、安定性は未知数
YOLO26で採用されたMuSGDは、両者の長所を活かすため、同一ステップ内で両方の更新を適用する手法です。「切り替え」ではなく「足し算」がポイントです。
MuSGDの更新手順
MuSGDクラスは、パラメータグループごとにuse_muonフラグで動作を切り替えます:
class MuSGD(optim.Optimizer):
def __init__(
self,
params,
lr: float = 1e-3,
momentum: float = 0.0,
weight_decay: float = 0.0,
nesterov: bool = False,
use_muon: bool = False,
muon: float = 0.5,
sgd: float = 0.5,
):
defaults = dict(
lr=lr,
momentum=momentum,
weight_decay=weight_decay,
nesterov=nesterov,
use_muon=use_muon,
)
super().__init__(params, defaults)
self.muon = muon # Muon更新のスケーリング係数
self.sgd = sgd # SGD更新のスケーリング係数
use_muon=Trueのパラメータグループに対して、以下の順序で更新が行われます:
@torch.no_grad()
def step(self, closure=None):
for group in self.param_groups:
if group["use_muon"]:
for p in group["params"]:
lr = group["lr"]
if p.grad is None:
continue
grad = p.grad
state = self.state[p]
# state初期化(初回のみ)
if len(state) == 0:
state["momentum_buffer"] = torch.zeros_like(p)
state["momentum_buffer_SGD"] = torch.zeros_like(p)
# ① Muon更新
update = muon_update(
grad,
state["momentum_buffer"],
beta=group["momentum"],
nesterov=group["nesterov"]
)
p.add_(update.reshape(p.shape), alpha=-(lr * self.muon))
# ② SGD更新
if group["weight_decay"] != 0:
grad = grad.add(p, alpha=group["weight_decay"])
state["momentum_buffer_SGD"].mul_(group["momentum"]).add_(grad)
sgd_update = (
grad.add(state["momentum_buffer_SGD"], alpha=group["momentum"])
if group["nesterov"]
else state["momentum_buffer_SGD"]
)
p.add_(sgd_update, alpha=-(lr * self.sgd))
重要なポイント:
-
別々のモメンタムバッファ:
momentum_buffer(Muon用)とmomentum_buffer_SGD(SGD用)を独立に管理 - 順次適用:同じパラメータに対して、Muon更新→SGD更新の順に適用
-
スケーリング係数:デフォルトで
self.muon=0.5、self.sgd=0.5により学習率を分割 - weight decayの適用:SGD側でのみ適用される
パラメータグループの設定
実際の使用例では、レイヤーの種類によってMuonを適用するかどうかを制御します:
# 畳み込み層とLinear層にはMuonを適用
muon_params = []
sgd_params = []
for name, module in model.named_modules():
if isinstance(module, (nn.Conv2d, nn.Linear)):
if hasattr(module, "weight") and module.weight.requires_grad:
muon_params.append(module.weight)
else:
for p in module.parameters(recurse=False):
if p.requires_grad:
sgd_params.append(p)
optimizer = MuSGD(
[
{
"params": muon_params,
"lr": 0.02,
"use_muon": True,
"momentum": 0.95,
"nesterov": True,
"weight_decay": 0.01,
},
{
"params": sgd_params,
"lr": 0.01,
"use_muon": False,
"momentum": 0.9,
"nesterov": False,
"weight_decay": 0,
},
],
muon=0.5,
sgd=0.5,
)
このようにパラメータグループを分けることで、2D以上の重み(例:Conv/Linearのweight)には Muon+SGD(use_muon=True)を適用し、それ以外のパラメータには SGD(use_muon=False)を適用するといった使い分けができます。
MuSGDは、Muonの更新(更新量を直交化して偏りを抑える方向に整形する)と、従来のSGD更新を同一ステップで組み合わせるハイブリッド型オプティマイザです。YOLO26では学習面のイノベーションとしてMuSGDが導入され、安定性や収束の改善が狙われています。
Newton–Schulz反復の係数など内部計算の詳細を全て理解する必要はありません。「新しい最適化手法により学習を改善した」という点を押さえておけば十分です。
アーキテクチャの微修正
YOLO26とYOLO11は使用しているモジュールは完全に同じで、どちらもC3k2を中心としたアーキテクチャです。ただし、YOLO26ではC3k2の呼び出され方と、C3k2自体の構造に変更が加えられています。
以下にC3k2のソースコードを示します。
class C3k2(C2f):
def __init__(
self,
c1: int,
c2: int,
n: int = 1,
c3k: bool = False, # TrueでC3k構造、FalseでBottleneck構造を使用
e: float = 0.5,
attn: bool = False, # YOLO26で追加:TrueでPSABlockを使用
g: int = 1,
shortcut: bool = True,
):
super().__init__(c1, c2, n, shortcut, g, e)
self.m = nn.ModuleList(
nn.Sequential(
Bottleneck(self.c, self.c, shortcut, g),
PSABlock(self.c, attn_ratio=0.5, num_heads=max(self.c // 64, 1)), # Attention機構
)
if attn # attn=Trueの場合:Bottleneck + PSABlock
else C3k(self.c, self.c, 2, shortcut, g) # c3k=Trueの場合:C3k構造
if c3k
else Bottleneck(self.c, self.c, shortcut, g) # デフォルト:Bottleneck
for _ in range(n)
)
class C2f(nn.Module):
def __init__(self, c1: int, c2: int, n: int = 1, shortcut: bool = False, g: int = 1, e: float = 0.5):
super().__init__()
self.c = int(c2 * e) # hidden channels
self.cv1 = Conv(c1, 2 * self.c, 1, 1)
self.cv2 = Conv((2 + n) * self.c, c2, 1)
self.m = nn.ModuleList(Bottleneck(self.c, self.c, shortcut, g, k=((3, 3), (3, 3)), e=1.0) for _ in range(n))
def forward(self, x: torch.Tensor) -> torch.Tensor:
y = list(self.cv1(x).chunk(2, 1))
y.extend(m(y[-1]) for m in self.m)
return self.cv2(torch.cat(y, 1))
このソースコードを踏まえて、3つの変更点を説明します。
1. N/SモデルのネックにおけるC3k構造の全面採用
YOLO11のN/Sモデルでは、ネックの最後のC3k2を除きc3k=FalseでBottleneckを使用していました。
# YOLO11
[-1, 2, C3k2, [512, False]] # Bottleneckを使用
YOLO26では全てc3k=Trueに統一され、C3k構造を使用します。
# YOLO26
[-1, 2, C3k2, [512, True]] # C3kを使用
2. 最終C3k2の内部ブロック繰り返し回数削減(L/Xモデル)
YOLO11のL/Xモデルでは最終C3k2の内部ブロック(self.m)を2回繰り返していましたが、YOLO26では1回に削減されました。
# 11/YOLO11.yaml
[-1, 2, C3k2, [1024, True]] # n=2(内部で2回処理)
# 26/YOLO26.yaml
[-1, 1, C3k2, [1024, True, 0.5, True]] # n=1(内部で1回処理)
3. 最終C3k2へのAttention機構の追加
C3k2にattn引数が追加され、attn=Trueの場合はBottleneckの後にPSABlockが適用されます。
# attn=Trueの場合
nn.Sequential(
Bottleneck(self.c, self.c, shortcut, g),
PSABlock(self.c, attn_ratio=0.5, num_heads=max(self.c // 64, 1)),
)
このPSABlockはYOLO11のC2PSAで使用されていたAttention機構です。YOLO26では最終C3k2のみで使用されます。
以上がYOLO26におけるアーキテクチャの変更点です。変更1はN/Sモデル、変更2はL/Xモデル、変更3は全モデルサイズに適用されます。これらの変更は学習手法の改善と組み合わせることで、YOLO11からの性能向上に寄与しています。
まとめ
本記事で説明したYOLO26の改良点を以下にまとめます。
YOLO26の設計方針は、NMSとDFLの削除による推論速度向上とデプロイ簡略化です。
NMS-Free化の実現
- 学習時:one-to-manyヘッドとone-to-oneヘッドの2つを使用し、十分な学習信号を確保
- 推論時:one-to-oneヘッドのみを使用することで重複検出を元から抑制し、後処理のNMSを不要化
DFL撤廃による簡素化
-
reg_max=1の直接回帰方式に変更 - ヘッドの出力次元を削減し、推論処理を簡素化
学習安定化のための施策
この大きな変更に伴う学習の不安定化に対処するため、以下を導入:
- ProgLoss:学習進行に応じてone-to-manyヘッドとone-to-oneヘッドの損失重みを動的に調整し、段階的に学習
- STAL:小物体検出で正例候補が0になる問題を解決する割り当て戦略
- MuSGD:MuonとSGDを組み合わせた最適化手法により、収束の安定化と高速化を実現
アーキテクチャ
YOLO11をベースとし、C3k2モジュールの呼び出し方と内部構造に小規模な変更を加えたのみ。大きな構造変更はありません。
参考文献
- YOLO26公式ドキュメント:Ultralytics YOLO26 Documentation
- YOLO26公式改良点概要:How Ultralytics YOLO26 trains smarter with ProgLoss, STAL, and MuSGD
- Ultralytics GitHub:ultralytics/ultralytics
- YOLO11公式ドキュメント:Ultralytics YOLO11 Documentation
- YOLOv10論文:YOLOv10: Real-Time End-to-End Object Detection
- TAL論文:TOOD: Task-aligned One-stage Object Detection
- GFL論文:Generalized Focal Loss: Learning Qualified and Distributed Bounding Boxes for Dense Object Detection
- Muon論文:Muon is Scalable for LLM Training
