Juliaの線形代数:adjoint()とadjoint!()の賢い使い分け

2025-05-27

LinearAlgebra.adjoint!() とは?

Julia の LinearAlgebra モジュールに含まれる adjoint! 関数は、行列またはベクトルの**随伴(adjoint)**を計算する関数です。ただし、この関数名の末尾にある ! は、**破壊的(in-place)**な操作であることを意味します。つまり、引数として渡されたオブジェクト自体を修正し、その結果を返します。

**随伴(Adjoint)とは、数学的には共役転置(conjugate transpose)**を指します。行列 A の共役転置 A∗ は、以下の2つの操作を組み合わせたものです。

  1. 転置 (Transpose): 行と列を入れ替えます。
  2. 複素共役 (Complex Conjugate): 各要素の虚数部分の符号を反転させます(例えば、a+bi は a−bi になります)。

実数のみの行列やベクトルの場合、複素共役は意味を持たないので、随伴は単なる転置と同じになります。

adjoint()adjoint!() の違い

Julia には、adjoint() という非破壊的な関数も存在します。

  • adjoint!(A): 行列 A の随伴を計算し、その結果を元の A 自体に上書きします。この操作は A を直接変更するため、注意が必要です。
  • adjoint(A): 行列 A の随伴を計算し、新しい Adjoint 型のオブジェクトを返します。元の A は変更されません。多くの場合、これは元のデータへの「ビュー(view)」として機能し、実際のデータコピーは行われないため、効率的です。

使用例

using LinearAlgebra

# 複素数を含む行列の場合
A = [1+2im 3+4im; 5+6im 7+8im]

println("元の行列 A:")
println(A)
# 出力例:
# 元の行列 A:
# 2×2 Matrix{Complex{Int64}}:
#  1+2im  3+4im
#  5+6im  7+8im

# adjoint!() を使用
adjoint!(A) # A の内容が変更される

println("\nadjoint!(A) 後の A:")
println(A)
# 出力例:
# adjoint!(A) 後の A:
# 2×2 Matrix{Complex{Int64}}:
#  1-2im  5-6im
#  3-4im  7-8im

# 実数のみの行列の場合
B = [1 2; 3 4]

println("\n元の行列 B:")
println(B)
# 出力例:
# 元の行列 B:
# 2×2 Matrix{Int64}:
#  1  2
#  3  4

# adjoint!() を使用
adjoint!(B) # B の内容が変更される (この場合は転置と同じ)

println("\nadjoint!(B) 後の B:")
println(B)
# 出力例:
# adjoint!(B) 後の B:
# 2×2 Matrix{Int64}:
#  1  3
#  2  4

いつ adjoint!() を使うべきか?

  • 計算グラフや連鎖的な操作で、中間結果をその場で更新したい場合: 例えば、最適化アルゴリズムなどで勾配を計算し、その場で更新していくような場合に役立ちます。
  • メモリ効率が重要で、元のデータを上書きしても問題ない場合: 特に大きな行列を扱う場合、新しいメモリを割り当てる必要がないため、adjoint!() はメモリ使用量を抑え、パフォーマンスを向上させることができます。
  • 型に対する注意: adjoint! は、対象となる行列やベクトルの要素型が複素数を扱えるかどうかによって挙動が異なります。実数型の場合は転置と同義になります。
  • 元のデータが変更される: adjoint!() を使用すると、元のオブジェクトの内容が永久に変更されます。元のデータが必要な場合は、事前にコピーを取るか、非破壊的な adjoint() 関数を使用してください。


MethodError: no method matching adjoint!(::SomeImmutableType)

エラーの原因
adjoint!() は引数として渡されたオブジェクト自体を変更するため、変更不可能な(immutable)型のオブジェクトに対しては呼び出すことができません。数値(Int, Float64, ComplexF64 など)やタプルなどは変更不可能な型です。行列やベクトルは通常、変更可能な(mutable)型ですが、特定の構造体やユーザー定義型が変更不可能な場合もあります。


x = 5 # Int は immutable
# adjoint!(x) # -> MethodError: no method matching adjoint!(::Int64)

t = (1, 2, 3) # タプルは immutable
# adjoint!(t) # -> MethodError: no method matching adjoint!(::Tuple{Int64, Int64, Int64})

トラブルシューティング

  • 変更可能なオブジェクトを期待しているのにエラーが出る場合
    渡している変数の型を確認してください。意図せず変更不可能な型になっている可能性があります。
  • 変更不可能なオブジェクトを渡している場合
    adjoint!() ではなく、非破壊的な adjoint() 関数を使用してください。これは新しいオブジェクトを返します。
    x = 5
    y = adjoint(x) # 5 のまま (実数の adjoint はそれ自身)
    println(y)
    
    c = 1 + 2im
    d = adjoint(c) # 1 - 2im
    println(d)
    

ERROR: DimensionMismatch("matrix A has dimensions (M, N) but adjointed matrix has dimensions (N, M)") (または類似のエラー)

エラーの原因
adjoint!() 自体が直接このエラーを出すことは稀ですが、adjoint!() の結果を別の行列演算に渡した場合に発生することがあります。adjoint!() は行列の形状を変更します(行と列が入れ替わるため)。例えば、M×N の行列 A に adjoint!(A) を適用すると、A は N×M の行列になります。この変更された A を、元の M×N の形状を期待する別の演算に渡すと、次元の不一致エラーが発生します。


A = rand(2, 3) # 2x3 行列
B = rand(3, 2) # 3x2 行列

# adjoint!(A) を適用すると A は 3x2 になる
adjoint!(A)

# B * A は B (3x2) * A (3x2) となり、次元が合わない
# C = B * A # -> DimensionMismatch

トラブルシューティング

  • 元の行列の形状を保持したい場合は、adjoint!() ではなく、新しい Adjoint 型のビューを返す adjoint() を使用するか、copy(adjoint(A)) のように明示的にコピーを作成してください。
  • 後続の演算が期待する次元と一致するように、行列の順序や他の行列の次元を調整してください。
  • adjoint!() を適用した後の行列の次元を確認してください。size(A) で確認できます。

adjoint! が期待通りに「ビュー」ではない振る舞いをする

エラーの原因
これはエラーというよりは誤解に基づく問題です。adjoint(A) は通常 Adjoint 型のビューを返しますが、adjoint!(A) は元の行列 A を直接変更します。したがって、adjoint! を使った後に元の A の内容が変更されているのは、正しい挙動です。しかし、この破壊的な挙動を意識していないと、バグにつながることがあります。


A = [1 2; 3 4]
B = A # A への参照
adjoint!(A) # A の内容が変更される

println("A: \n", A)
println("B: \n", B) # B も A と同じオブジェクトを参照しているため、B の内容も変更されている

トラブルシューティング

  • コードの意図を明確にする
    adjoint! を使う場合は、元のオブジェクトが変更されることをコメントなどで明記し、他の開発者が誤解しないようにしましょう。
  • 破壊的な変更を避けたい場合
    • A を変更せずに随伴を得たい場合は adjoint(A) を使用してください。これは新しい Adjoint 型のオブジェクト(通常はビュー)を返します。
    • A の内容を保持しつつ、随伴の計算結果を独立した新しい配列として得たい場合は B = copy(adjoint(A)) としてください。これにより、A とは独立した新しい配列 B が作成されます。

adjoint! が Missing や Nothing を含む行列でエラーを出す

エラーの原因
adjoint! は数値的な操作を想定しているため、行列内に MissingNothing のような特殊な値が含まれていると、それらの値に対する随伴の定義がないために MethodError を発生させることがあります。


M = [1 2; missing 4]
# adjoint!(M) # -> MethodError: no method matching adjoint(::Missing)

トラブルシューティング

  • これらの値が含まれる可能性がある場合は、事前にデータクレンジングを行うか、adjoint 操作の前にそれらの値を適切に処理してください(例: 補完、除外など)。
  • 行列のデータ型を確認し、MissingNothing が含まれていないか確認してください。

特殊な行列型(Diagonal, Symmetric など)での挙動の理解不足

エラーの原因
Julia の LinearAlgebra モジュールは、Diagonal, Symmetric, Hermitian などの特殊な行列型に対して最適化されたメソッドを提供しています。adjoint! はこれらの型に対しても機能しますが、その挙動は通常の Matrix とは異なる場合があります。例えば、Diagonal 行列の随伴は Diagonal 型のままになるかもしれません。


D = Diagonal([1, 2, 3])
println("元の Diagonal: ", typeof(D)) # Diagonal{Int64, Vector{Int64}}
adjoint!(D)
println("adjoint! 後の Diagonal: ", typeof(D)) # Diagonal{Int64, Vector{Int64}} のまま
println(D) # 対角要素の複素共役が取られる(実数の場合は変わらない)

この場合、型は Diagonal のままであり、非対角要素はゼロのままです。期待する挙動が「フルな行列(Matrix)に変換すること」であった場合、これは期待外れかもしれません。

  • 必要に応じて、Matrix(A) のように明示的に標準の Matrix 型に変換してから adjoint!() を適用することを検討してください。ただし、これによりメモリの割り当てが発生することに注意してください。
  • 特殊な行列型を扱っている場合は、その型の adjoint! のドキュメントや挙動を確認してください。


using LinearAlgebra を忘れずに記述してください。

例 1: 基本的な使い方 - 複素数行列の場合

最も基本的な例として、複素数を含む行列に adjoint!() を適用してみましょう。

using LinearAlgebra

println("--- 例 1: 基本的な使い方 - 複素数行列の場合 ---")

# 2x2 の複素数行列を作成
A = [1 + 2im    3 - 1im;
     -4 + 5im   6 + 7im]

println("元の行列 A:")
println(A)
# 出力例:
# 元の行列 A:
# 2×2 Matrix{Complex{Int64}}:
#  1+2im   3-1im
# -4+5im  6+7im

# A の随伴を計算し、A 自体を変更する
adjoint!(A)

println("\nadjoint!(A) 後の行列 A:")
println(A)
# 出力例:
# adjoint!(A) 後の行列 A:
# 2×2 Matrix{Complex{Int64}}:
#  1-2im  -4-5im
#  3+1im   6-7im

# 検証: 結果が正しいか確認
# 元の A の共役転置を手動で計算:
# [ (1-2im)  (-4-5im) ; (3+1im)  (6-7im) ]
# と一致していることがわかる

解説
adjoint!(A) が実行された後、A の内容が完全に書き換えられていることがわかります。各要素の複素共役が取られ、行と列が入れ替わっています。

例 2: 実数行列の場合 - 転置と同じ挙動

実数のみの行列の場合、随伴は単なる転置と同じになります。

using LinearAlgebra

println("\n--- 例 2: 実数行列の場合 - 転置と同じ挙動 ---")

# 3x2 の実数行列を作成
B = [1 2;
     3 4;
     5 6]

println("元の行列 B:")
println(B)
# 出力例:
# 元の行列 B:
# 3×2 Matrix{Int64}:
#  1  2
#  3  4
#  5  6

# B の随伴を計算し、B 自体を変更する
adjoint!(B)

println("\nadjoint!(B) 後の行列 B:")
println(B)
# 出力例:
# adjoint!(B) 後の行列 B:
# 2×3 Matrix{Int64}:
#  1  3  5
#  2  4  6

# 検証: 結果が転置と同じか確認
# [ 1 3 5 ; 2 4 6 ]
# と一致している

解説
B は 3×2 から 2×3 になり、要素が転置されていることが確認できます。実数なので、複素共役の影響はありません。

例 3: ベクトルに対する adjoint!()

ベクトルに対しても adjoint!() を適用できます。列ベクトルは行ベクトルに、行ベクトルは列ベクトルに変わります。

using LinearAlgebra

println("\n--- 例 3: ベクトルに対する adjoint!() ---")

# 列ベクトルを作成
v_col = [1 + im, 2 - 3im, 4 + 0im]
println("元の列ベクトル v_col:")
println(v_col)
# 出力例:
# 元の列ベクトル v_col:
# 3-element Vector{Complex{Int64}}:
#  1+1im
#  2-3im
#  4+0im

adjoint!(v_col)
println("\nadjoint!(v_col) 後の v_col (行ベクトルに):")
println(v_col)
# 出力例:
# adjoint!(v_col) 後の v_col (行ベクトルに):
# 1×3 Adjoint{Complex{Int64}, Vector{Complex{Int64}}}:
#  1-1im  2+3im  4-0im

# 行ベクトルを作成 (明示的に 1xN 行列として)
v_row = [1 2 3] # これは 1x3 の行列
println("\n元の行ベクトル v_row (1x3 行列):")
println(v_row)
# 出力例:
# 元の行ベクトル v_row (1x3 行列):
# 1×3 Matrix{Int64}:
#  1  2  3

adjoint!(v_row)
println("\nadjoint!(v_row) 後の v_row (列ベクトルに):")
println(v_row)
# 出力例:
# adjoint!(v_row) 後の v_row (列ベクトルに):
# 3×1 Matrix{Int64}:
#  1
#  2
#  3

解説

  • v_row は元々 1x3 Matrix{Int64} 型だったので、adjoint!(v_row) 適用後は 3x1 Matrix{Int64} に変わり、完全に元の行列が上書きされます。
  • v_colVector{Complex{Int64}} 型でしたが、adjoint!(v_col) 適用後は Adjoint{Complex{Int64}, Vector{Complex{Int64}}} 型の 1x3 行列ビューに変わります。これは adjoint() 関数が返す型と似ていますが、元のベクトルデータへの参照であり、その次元の解釈が変わります。

例 4: adjoint()adjoint!() の比較と注意点

破壊的な操作であることを強調する例です。

using LinearAlgebra

println("\n--- 例 4: adjoint() と adjoint!() の比較と注意点 ---")

original_matrix = [1+im 2-im; 3+im 4-im]
println("元の行列 original_matrix:")
println(original_matrix)

# 1. 非破壊的な adjoint() を使用
view_matrix = adjoint(original_matrix)
println("\nadjoint(original_matrix) で得られた view_matrix (ビュー):")
println(view_matrix)
println("original_matrix の内容は変化なし:")
println(original_matrix) # original_matrix は変わっていない

# view_matrix はビューなので、元の original_matrix を介してデータが共有されている
# 例: original_matrix の要素を変更すると view_matrix にも影響
original_matrix[1,1] = 100 + 100im
println("\noriginal_matrix[1,1] を変更後:")
println("original_matrix:\n", original_matrix)
println("view_matrix (ビューなので変化する):\n", view_matrix)


# 2. 破壊的な adjoint!() を使用
another_matrix = [10+im 20-im; 30+im 40-im]
println("\n\n別の行列 another_matrix:")
println(another_matrix)

adjoint!(another_matrix) # another_matrix 自体が変更される
println("\nadjoint!(another_matrix) 後の another_matrix:")
println(another_matrix)

# この時点で元の another_matrix の内容は失われている

解説
adjoint() は元のデータを変更せず、元のデータへの「ビュー」を返します。そのため、元のデータを変更すると、ビューにもその変更が反映されます。一方、adjoint!() は元のデータを直接上書きするため、元のデータは失われます。この違いを理解することが、バグの回避に繋がります。

例 5: パフォーマンスとメモリ効率 (発展的な内容)

大規模な行列で adjoint!() を使う利点は、新しいメモリ割り当てが不要な点です。

using LinearAlgebra
using BenchmarkTools # パフォーマンス測定用

println("\n--- 例 5: パフォーマンスとメモリ効率 ---")

# 大きな行列を作成
N = 2000
large_matrix = rand(ComplexF64, N, N) # N x N の複素数行列

println("N = $N の行列でのパフォーマンス比較:")

# adjoint() (非破壊的) の場合
println("\n@benchmark adjoint(large_matrix):")
@btime adjoint(large_matrix);
# 出力例: (環境により変動)
#   1.233 ns (0 allocations: 0 bytes) # 通常はビューなので高速でメモリ割り当てなし

# adjoint!() (破壊的) の場合
println("\n@benchmark adjoint!(large_matrix):")
@btime adjoint!(large_matrix);
# 出力例: (環境により変動)
#   1.042 ms (0 allocations: 0 bytes) # ビューの作成コストがないため、純粋な計算時間。メモリ割り当てはなし

# copy(adjoint(large_matrix)) の場合 (新しい行列を生成)
new_large_matrix = rand(ComplexF64, N, N) # 元の行列をリセット
println("\n@benchmark copy(adjoint(new_large_matrix)):")
@btime copy(adjoint(new_large_matrix));
# 出力例: (環境により変動)
#   24.582 ms (3 allocations: 30.52 MiB) # 新しい行列の生成とデータコピーのため、時間もメモリもかかる

解説
adjoint() はほとんど瞬時に実行され、メモリ割り当てもありません。これは、実際のデータコピーを行わず、元の行列への「ビュー」を返すためです。 adjoint!() もメモリ割り当てはゼロですが、行列の要素を実際に上書きするため、ある程度の計算時間がかかります。 copy(adjoint(large_matrix)) は、新しい行列を割り当ててデータをコピーするため、最も時間がかかり、メモリも消費します。

したがって、元の行列を上書きしても問題ない場合は、adjoint!() は非常に効率的な選択肢となります。



ここでは、LinearAlgebra.adjoint!() の代替となるプログラミング方法と、それぞれの選択肢がどのようなシナリオで役立つかを説明します。

主な代替方法は以下の3つです。

  1. adjoint(A)
    非破壊的な随伴計算。最も一般的で推奨される代替方法。
  2. permutedims!(A) (転置のみ)
    複素共役が不要で、転置のみを破壊的に行いたい場合。
  3. 手動でのループ処理
    特定のカスタマイズが必要な場合や、教育目的。

adjoint(A): 非破壊的な随伴計算 (最も推奨)

  • 適切なシナリオ
    • 元の行列のデータを変更したくない場合。
    • 行列の随伴を計算し、それを別の行列演算に渡す場合(例: A∗B)。
    • メモリ使用量を最小限に抑えたい場合。
  • 欠点
    • ビューであること
      返されるのは Matrix 型のコピーではなく、Adjoint 型のビューであるため、その後の操作で新しい配列が必要な場合は明示的に copy() する必要があります。
  • 利点
    • 非破壊的
      元のデータが保持されるため、予期せぬ副作用を防ぎます。
    • メモリ効率
      実際のデータコピーが発生しないため、大規模な行列でもメモリ使用量が少ないです。
    • 高速
      ビューの作成は非常に高速です。
    • 通常の行列演算と互換性
      Adjoint 型のオブジェクトは、通常の行列と同様に多くの行列演算(乗算、加算など)で使用できます。

コード例

using LinearAlgebra

println("--- 1. adjoint(A): 非破壊的な随伴計算 ---")

A = [1+2im 3+4im; 5+6im 7+8im]
println("元の行列 A:")
println(A)

# adjoint() を使用: A の内容を変化させずに、随伴のビューを作成
A_adj_view = adjoint(A)
println("\nadjoint(A) で得られた A_adj_view (ビュー):")
println(A_adj_view)

println("\n元の行列 A は変更されていないことを確認:")
println(A)

# A_adj_view はビューなので、A の要素を変更すると影響を受ける
A[1,1] = 100 + 200im
println("\nA[1,1] を変更後:")
println("A:\n", A)
println("A_adj_view (ビューなので変化する):\n", A_adj_view)

# 明示的に新しい行列としてコピーしたい場合
A_adj_copied = copy(adjoint(A))
println("\ncopy(adjoint(A)) で得られた A_adj_copied (新しい行列):")
println(A_adj_copied)
A[1,1] = 1 + 2im # 元に戻しても A_adj_copied は変化しない
println("A の変更は A_adj_copied に影響しない:\n", A_adj_copied)

permutedims!(A) (転置のみ)

  • 適切なシナリオ
    • 実数行列の転置を破壊的に行いたい場合。
    • 行列の要素型が複素数であっても、複素共役は取らずに転置のみを行いたい場合。
  • 欠点
    • 複素共役は行わない
      複素数を含む行列の場合、数学的な「随伴」にはなりません。
  • 利点
    • 破壊的
      メモリを節約し、インプレースで転置を行います。
    • 純粋な転置
      複素共役が不要な場合に、余分な計算を避けられます。

コード例

using LinearAlgebra # permutedims! は LinearAlgebra にはないが、通常の使用では併用されるため記載

println("\n--- 2. permutedims!(A): 転置のみ (破壊的) ---")

# 実数行列の場合
B = [1 2; 3 4; 5 6]
println("元の行列 B:")
println(B)

permutedims!(B) # B を破壊的に転置
println("\npermutedims!(B) 後の行列 B:")
println(B)

# 複素数行列の場合
C = [1+2im 3+4im; 5+6im 7+8im]
println("\n元の行列 C (複素数):")
println(C)

permutedims!(C) # C を破壊的に転置 (複素共役は取られない)
println("\npermutedims!(C) 後の行列 C (複素共役なし):")
println(C)

# 比較: adjoint!(C) の場合
C_adj = [1+2im 3+4im; 5+6im 7+8im] # 元に戻す
adjoint!(C_adj)
println("\nadjoint!(C_adj) 後の行列 C_adj (複素共役あり):")
println(C_adj)

手動でのループ処理 (特殊なケース)

  • 適切なシナリオ
    • ほとんどない
      通常は組み込み関数を使用すべきです。ただし、例えば行列の特定のブロックだけを随伴したい、あるいは随伴とは異なるがそれに近いカスタム変換を行いたいなど、非常にニッチな要件がある場合に検討されるかもしれません。
  • 欠点
    • パフォーマンス
      通常、Julia の最適化された組み込み関数(adjoint!, adjoint, permutedims! など)よりも大幅に遅くなります。
    • コードの複雑さ
      記述量が増え、エラーが発生しやすくなります。
    • メモリ割り当て
      通常、新しい行列を割り当てる必要があります(破壊的に実装することも可能ですが、さらに複雑になります)。
  • 利点
    • 完全なカスタマイズ性
      要素ごとの操作を完全に制御できます。
    • 学習目的
      行列操作の内部挙動を理解するのに役立ちます。

コード例 (新しい行列を生成する場合)

println("\n--- 3. 手動でのループ処理 (新しい行列を生成) ---")

D = [1+im 2+2im; 3+3im 4+4im]
println("元の行列 D:")
println(D)

# 手動で随伴を計算し、新しい行列に格納
rows, cols = size(D)
D_manual_adj = Matrix{eltype(D)}(undef, cols, rows) # 新しい行列を事前に確保

for i in 1:rows
    for j in 1:cols
        D_manual_adj[j, i] = conj(D[i, j]) # 転置と複素共役
    end
end

println("\n手動ループで計算された D_manual_adj:")
println(D_manual_adj)

# 比較: adjoint(D) の結果
println("\nadjoint(D) の結果 (比較用):")
println(adjoint(D))

コード例 (破壊的に手動ループ)

破壊的に手動ループを実装するのは複雑で、元の行列の次元が変わる場合(例: M×N から N×M)には、事前に新しいメモリを確保してからそこにコピーし、元の変数に割り当て直すのが一般的です。真にインプレースで次元を変更するのは、非常に特殊なメモリ管理が必要となり、通常の Julia コードではほとんど行いません。

println("\n--- 3b. 手動でのループ処理 (破壊的風) ---")

# この方法は、厳密には「真のインプレース破壊的」ではないことに注意。
# 次元が変わるため、新しいメモリを確保し、元の変数に再代入している。

E = [1+im 2+2im; 3+3im 4+4im]
println("元の行列 E:")
println(E)

rows, cols = size(E)
if rows != cols # 正方行列でない場合、新しい配列が必要
    temp_E = Matrix{eltype(E)}(undef, cols, rows)
    for i in 1:rows
        for j in 1:cols
            temp_E[j, i] = conj(E[i, j])
        end
    end
    E = temp_E # 元の変数 E に新しい配列を再代入
else # 正方行列の場合、比較的インプレースに近い操作が可能だが、それでも複雑
    # 正方行列でも、要素のアクセス順序が複雑になるため、多くの場合一時配列を使う
    # ここでは単純化のため、非正方行列と同じロジックを踏襲する
    temp_E = Matrix{eltype(E)}(undef, cols, rows)
    for i in 1:rows
        for j in 1:cols
            temp_E[j, i] = conj(E[i, j])
        end
    end
    E = temp_E
end

println("\n手動ループで計算された E (破壊的風):")
println(E)

ほとんどのユースケースでは、LinearAlgebra.adjoint!(A) の代替としてadjoint(A) を使用することを強くお勧めします。これは非破壊的で、メモリ効率が良く、ほとんどのシナリオで十分なパフォーマンスを提供します。