はじめに
12月1日にTransformers v5がリリースされました。
5年ぶりのメジャーバージョンアップとのことで、かなり大きな変化なのかなと思います。
v4の時は1日2万回のインストールだったのが、今や1日300万回になっているみたいです!
すごい増加量ですよね。
v5のリリース記事を読んでいて、上の方に「Modulars Approach」という項目があり目に留まったので少し深掘りをしてみました。
Modular Approachとは?
2024/5/31にTransformersで導入されたアプローチです。
Transformersの原則の1つである「one model, one file」のルールを破らずに継承を可能にする手段として導入されました。やっていることとしては、他のモデルからコードをコピーして貼り付けるのではなく、クラスを継承することです。
「ただ単にクラス継承の仕組みではないか」とも思いますが、決定的に違うところがあります。それが、1ファイルに継承関係を全て展開するということです。本来コンパイラが行うべきことをあえて見える形にしています。1ファイルに全てコアなロジックが記載されているという状態が作られることで、「one model, one file」が守られます。
「one model, one file」の原則は以下のようになっています。
All inference and training core logic has to be visible, top‑to‑bottom, to maximize each model’s hackability.
日本語訳すると以下の通りです。
推論および学習の核となるロジックはすべて、上から下まで可視化されていなければならない。それは、各モデルのハック可能性を最大限に高めるためである。
機械学習モデルの進化の速さに対応するため、モデル同士の依存関係を意図的になくすことで可読性と適応性を持たせる方針として決められたみたいです。
関連する原則に「DRY*」があります。よく言われているDRY(Don't Repeat Yourself)原則とは逆で、「DRY*」は「DO Repeat Yourself」です。「one model, one file」の原則を守るため、「DRY*」の原則があるのは面白いなと思いました。
Modular Aproachではある意味「DRY*」の原則から逸脱しています。modular_*.pyのファイルではロジックを繰り返さず、展開されたmodule_*.pyによって「one model, one file」と「DRY*」の原則をどちらも満たします。
この変化はTransformesの原則の中で、大きな変化だったと思います。
Modular Aproachを実際に見てみる
Transformersに新しいモデルを追加するときにみるドキュメントで詳しく説明されているみたいです。今やmodularファイルでモデルを作るのがデフォルトのようですね。
MetaCLIP 2の実装を見てみる
それでは実際にHugging Faceにアップロードされているモデルの実装を見てみましょう。
今回取り上げるのはMetaCLIP 2です。
簡単に説明すると、2025-08-20にリリースされた多言語のVLMで、mSigLIPやSigLIP-2などのSOTAを上回っているモデルです。
論文を読むと、モデルのアーキテクチャはCLIPをそのまま使っていることがわかります。
変更があっても最低限の変更になっているようです。
本当にそうなっているか見てみましょう!
MetaCLIP2のmodular_metaclip_2.pyからの抜粋です。
# 201-214行目
class MetaClip2TextEmbeddings(CLIPTextEmbeddings):
pass
class MetaClip2VisionEmbeddings(CLIPVisionEmbeddings):
pass
class MetaClip2Attention(CLIPAttention):
pass
class MetaClip2MLP(CLIPMLP):
pass
EmbeddingやAttentionなどは完全にCLIPと一緒のものを使っていますね。
若干実装が違うところもみていきましょう。
まずはMetaCLIP 2の実装です。pooled_outputはeos_token_idを使った指定になっていて、BaseModelOutputWithPoolingにはlast_hidden_state・pooler_output・hidden_states・attentionsの4つが設定されていますね。
# modular_metaclip_2.py 279行目~
class MetaClip2TextTransformer(CLIPTextTransformer):
@auto_docstring
def forward(
self,
input_ids,
attention_mask: Optional[torch.Tensor] = None,
position_ids: Optional[torch.Tensor] = None,
**kwargs: Unpack[TransformersKwargs],
) -> BaseModelOutputWithPooling:
# ...他コード...
last_hidden_state = encoder_outputs.last_hidden_state
last_hidden_state = self.final_layer_norm(last_hidden_state)
# Use robust pooling like CLIP - finds the first EOS token position per sequence
pooled_output = last_hidden_state[
torch.arange(last_hidden_state.shape[0], device=last_hidden_state.device),
(input_ids.to(dtype=torch.int, device=last_hidden_state.device) == self.eos_token_id).int().argmax(dim=-1),
]
return BaseModelOutputWithPooling(
last_hidden_state=last_hidden_state,
pooler_output=pooled_output,
hidden_states=encoder_outputs.hidden_states,
attentions=encoder_outputs.attentions,
)
CLIPの実装の方を比べてみます。forward関数の引数はほぼ同じですが、pooled_outputの指定やBaseModelOutputWithPoolingの引数で若干違いが出ていますね。
# modeling_clip.py 518行目~
class CLIPTextTransformer(nn.Module):
def __init__(self, config: CLIPTextConfig):
# ...他コード...
@auto_docstring
def forward(
self,
input_ids: Optional[torch.Tensor] = None,
attention_mask: Optional[torch.Tensor] = None,
position_ids: Optional[torch.Tensor] = None,
**kwargs: Unpack[TransformersKwargs],
) -> BaseModelOutputWithPooling:
# ...他コード...
last_hidden_state = encoder_outputs.last_hidden_state
last_hidden_state = self.final_layer_norm(last_hidden_state)
if self.eos_token_id == 2:
# The `eos_token_id` was incorrect before PR #24773: Let's keep what have been done here.
# A CLIP model with such `eos_token_id` in the config can't work correctly with extra new tokens added
# ------------------------------------------------------------
# text_embeds.shape = [batch_size, sequence_length, transformer.width]
# take features from the eot embedding (eot_token is the highest number in each sequence)
# casting to torch.int for onnx compatibility: argmax doesn't support int64 inputs with opset 14
pooled_output = last_hidden_state[
torch.arange(last_hidden_state.shape[0], device=last_hidden_state.device),
input_ids.to(dtype=torch.int, device=last_hidden_state.device).argmax(dim=-1),
]
else:
# The config gets updated `eos_token_id` from PR #24773 (so the use of extra new tokens is possible)
pooled_output = last_hidden_state[
torch.arange(last_hidden_state.shape[0], device=last_hidden_state.device),
# We need to get the first position of `eos_token_id` value (`pad_token_ids` might equal to `eos_token_id`)
# Note: we assume each sequence (along batch dim.) contains an `eos_token_id` (e.g. prepared by the tokenizer)
(input_ids.to(dtype=torch.int, device=last_hidden_state.device) == self.eos_token_id)
.int()
.argmax(dim=-1),
]
return BaseModelOutputWithPooling(
last_hidden_state=last_hidden_state,
pooler_output=pooled_output,
)
ぜひ気になった方は他の実装も見てみてください。ざっとみた感じMetaCLIP 2の方にはほとんど変更はありませんでしたね。
これでほとんどCLIPと同じ実装であることが確認できました。
modular_metaclip_2.pyの行数は776行で、展開されたmodeling_metaclip_2.pyの行数は1265行でしたので、展開後と比べてかなり行数は削減できているなという印象でした。さらにmodular_metaclip_2.pyの方はコメント部分がかなり多かったので実際に違いのある変更はもっと少なかったです。
modularを使うとモデルを作る方も書きやすいだろうなと思いましたし、読む方もどこが変更されたのか分かり易いなと思いました。また、modeling_*.pyの方も合わせて見ることでロジックが全て記載されていることの安心感もありました。
最後に
本記事ではTransformersのv5へのバージョンアップをきっかけにModular Approachについて深掘りしてみました。
深掘りを通してTransformersの原則について知る良い機会になりました。一見単純そうに見えるルールでも強い意図があって運用されているんだということがわかりました。
今後もライブラリがどのような方針で作られているのかを知りつつ、使っていければと思います。
最後まで読んでいただきありがとうございました!