Stable Diffusionを使ったイラスト作成の記録(16) ~ ネガティブプロンプトの重みづけ ~
前回の記事
シリーズ一覧
Layered Diffusion Pipelineを使うためのリンク集
ライブラリの入手先と使用法(英語) : Githubリポジトリ
日本語での使用方法の解説 : Noteの記事
プロンプトの重みづけとは何か?
非常に単純化した説明として、プロンプトとして与えられた文字列は、トークナイザーによってトークン(token)に分割され、さらにテキストエンコーダーによって各トークンが数値ベクトル(embedding)に変換されます。
プロンプトの重みづけは、このembeddingに対応するトークンの重みを掛け算してトークンの効果を強めようとすることを指しています。このembeddingは、その後、画像生成プロセスでノイズ除去のUNetの入力として使われます。
この手法には大きく2つの問題点があります。
トークンとembeddingの関係は1対1ではないので、トークンに対応するembeddingだけに掛け算することに、理論的な裏付けがない。
UNetは複雑なニューラルネットワークで、embeddingベクトルの長さを大きくすることが、トークンの影響を強めることになるという理論的な裏付けがない。
しかし、これらの問題点にも関わらず、プロンプトの重みづけに一定程度の効果があることは、経験的に認知されています。
ShiftEncodingの場合
ShiftEncodingは、diffusersのコミュニティパイプラインにあるlpw_stable_diffusion.py(Long Prompt Weighting)の実装を参考にしていて、重みづけを計算した後、最後にembeddingベクトルの値の「平均値」が重みづけの前と同じになるように割り算しています。
しかし、おそらく、実際にはこの最後の調整は、ほとんど実質的な意味をなしておらず、無視しても構わない程度の影響しかないものと思われます。(なので、近いうちに削除しようと考えています。)
実験1「重みの計算方法を変更する」
ShiftEncodingの場合、重みを指定したトークンの位置のembeddingだけに重みが掛け算されます。しかし、プロンプト中の全ての単語に重みを付けても、トークナイザーから出力される全てのトークンに重みが付けられるわけではありません。
トークナイザーの実際の出力には、冒頭に開始文字(<|startoftext|>)、プロンプトが終わった後から最後まで終了文字(<|endoftext|>)が埋められています。特に、最初に出現した終了文字には、プロンプト全体の意味が含まれていると考えられています。
そこで、プロンプト全体に重みづけを行う場合、開始文字と終了文字にも重みを掛け算する方が適切な可能性があります。そこで、実験1では3種類の重みづけの計算を試しました。
ShiftEncoding -- プロンプトの単語だけ重みを掛け算する
開始文字と最初の終了文字にも重みを掛け算する
残りの終了文字を含む、全てのトークンに重みを掛け算する
スクリプト
# スクリプト(16-1)
image = pipe(
num_steps=30,
size=image_size,
rand_seed=rand_seed,
initialize=Randomly(strength=using(0.99999, until=1.0)),
iterate=[
Layer(
prompt=("1girl", StandardEncoding()),
negative_prompt=("monochrome", custom_encoding),
),
]
)
custom_encodingのところには、上で示した異なる重みづけを実装したエンコーディングを与えます。
生成画像
全ての方式において、同様の絵のタッチの変化が認められました。各方式における相違点は次の2点です。
重みを掛け算するトークンの数が増える(=より右側)につれ、重みの変化に対して絵のタッチが急速に変化する。
開始文字と最初の終了文字を含むかどうか(=最左列と真ん中の比較)が生成画像に与える変化は大きいが、残りの終了文字を含むかどうか(=真ん中と最右列の比較)はほとんど変化を起こさない。
実験2「embeddingの要素を全て一定値にする」
実験1では、ネガティブプロンプトとして"monochrome"というタグを与えましたが、ネガティブプロンプトに何も指定しない場合は空文字列がテキストエンコーダーに与えられ、空文字列に対するembeddingが計算されます。
しかし、空文字列のembeddingの代わりに、ゼロ行列を使うことはできないのでしょうか? あるいは、各要素が全て同じ値の行列にするとどうなるのでしょうか?
実験2では、実験1のスクリプト(16-1)を用い、custom_encodingにプロンプトを無視して常にゼロ行列、あるいは要素が全て指定した値の行列を返すエンコーディングを与えました。
ゼロ行列の生成画像
まず、ネガティブプロンプトにゼロ行列を与えた画像(左)と空文字列を与えた画像(右)の比較はこちらです。
ネガティブプロンプトにゼロ行列を与えた画像(左)の方が、くっきりした画像が生成されました。画像の内容に異常な点も見当たりません。
各要素が同じ値の行列の生成画像
各要素がゼロ以外の生成画像はこちらです。左列が正の値、右列が負の値で、上から下に行くにつれて絶対値が0.1, 0.2, 0.3, 0.4と増えていきます。
値が大きくなるにつれ、画像の抽象度が上がります。これは、重みを大きくした時の変化と類似しています。
値を正にした時と負にした時では変化に差がありました。正の時は彩度の高い画像が生成されましたが、負の時は彩度の低い画像となりました。
実験3「プロンプトにゼロ行列を与えた場合」
最後に、ゼロ行列や要素一定の行列がどういう画像に対応しているのかを確認するため、プロンプトにそれらの行列を与えた生成画像と空文字列を与えた生成画像を比較しました。なお、ネガティブプロンプトの影響を無視するため、CFGスケールは1に設定しています。
生成画像
空文字列から生成された画像に比べ、ゼロ行列から生成された画像はより不鮮明なものとなっています。プロンプトが空でもイラストが生成されるのは、Anything V3.0のモデルの特徴に依存しています。
行列要素の値をゼロ以外にすると、画像は完全にランダムなものになります。正の値では彩度が高く、負の値では彩度が低い特徴があるようです。彩度の傾向が、プロンプトでもネガティブプロンプトでも同様(正で高彩度、負で低彩度)になる点は、興味深い現象です。
まとめ
ネガティブプロンプトのembedding全体を重みづけ
ネガティブプロンプトのembeddingベクトルの全ての要素に一律の重みを掛け算することは、絵のタッチを変化させる手法として有用であることが示唆されました。
その場合、有効トークン(プロンプトの文字列に対応する部分のトークン)のみに重みの対象を限定する必要はなく、全ての要素に重みを掛けても問題ないようでした。
これを受け、ShiftEncodingにおける重みを、重みの平均値が1.0となるような標準化を掛けるように変更することで、部分への重みづけと全体への重みづけを分離することが、今後の課題として検討されます。
空文字列の代わりにゼロ行列を用いること
ネガティブプロンプトで、空文字列を与える代わりにゼロ行列を与えることが、有用であることが示唆されました。
また、行列要素がゼロ以外の一定値の行列をネガティブプロンプトに与えることも、興味深い結果を生み出しました。
ゼロ行列を与えることは、プロンプト全体に掛ける重みをゼロにすることと等しいので、上述の実験に加えて、1.0より小さい重みを与えた時の生成画像を比較することに、新たな発見がある可能性があります。