行为上的意外差异 with/out let block

Unexpected difference in behaviour with/out let block

我一直在使用 Flux.jl 并且对 运行 代码在 let 块内和不在块内的差异感到困惑。下面的例子运行没有错误:

using Flux

p = rand(2)
function f(x)
    f, b = p
    x*f + b
end

data = reduce(hcat, [[x, f(x)] for x in 0:0.1:1.0])

p = rand(2)
θ = params(p)

loss(y) = sum((y .- f.(data[1,:])).^2)

for n in 1:1000
    grads = Flux.gradient(θ) do
        loss(data[2,:])
    end
    Flux.Optimise.update!(ADAM(), θ, grads)
end

但是,将相同的代码包装在 let 块中并没有像我预期的那样工作:

using Flux

let
    ...
end

并生成堆栈跟踪:

MethodError: objects of type Float64 are not callable
  Maybe you forgot to use an operator such as [36m*, ^, %, / etc. [39m?

  Stacktrace:
    [1] macro expansion
      @ ~/.julia/packages/Zygote/bJn8I/src/compiler/interface2.jl:0 [inlined]
    [2] _pullback(ctx::Zygote.Context, f::Float64, args::Float64)
      @ Zygote ~/.julia/packages/Zygote/bJn8I/src/compiler/interface2.jl:9
    [3] (::Zygote.var"#1100#1104"{Zygote.Context, Float64})(x::Float64)
      @ Zygote ~/.julia/packages/Zygote/bJn8I/src/lib/broadcast.jl:186
    [4] _broadcast_getindex_evalf
      @ ./broadcast.jl:670 [inlined]
    [5] _broadcast_getindex
      @ ./broadcast.jl:643 [inlined]
    [6] getindex
      @ ./broadcast.jl:597 [inlined]
    [7] copy
      @ ./broadcast.jl:899 [inlined]
    [8] materialize
      @ ./broadcast.jl:860 [inlined]
    [9] _broadcast
      @ ~/.julia/packages/Zygote/bJn8I/src/lib/broadcast.jl:163 [inlined]
   [10] adjoint
      @ ~/.julia/packages/Zygote/bJn8I/src/lib/broadcast.jl:186 [inlined]
   [11] _pullback
      @ ~/.julia/packages/ZygoteRules/AIbCs/src/adjoint.jl:65 [inlined]
   [12] _apply
      @ ./boot.jl:814 [inlined]
   [13] adjoint
      @ ~/.julia/packages/Zygote/bJn8I/src/lib/lib.jl:200 [inlined]
   [14] _pullback
      @ ~/.julia/packages/ZygoteRules/AIbCs/src/adjoint.jl:65 [inlined]
   [15] _pullback
      @ ./broadcast.jl:1297 [inlined]
   [16] _pullback(::Zygote.Context, ::typeof(Base.Broadcast.broadcasted), ::Float64, ::Vector{Float64})
      @ Zygote ~/.julia/packages/Zygote/bJn8I/src/compiler/interface2.jl:0
   [17] _pullback
      @ ./In[198]:32 [inlined]
   [18] _pullback(::Zygote.Context, ::var"#loss#155", ::Vector{Float64}, ::Vector{Float64})
      @ Zygote ~/.julia/packages/Zygote/bJn8I/src/compiler/interface2.jl:0
   [19] _pullback
      @ ./In[198]:37 [inlined]
   [20] _pullback(::Zygote.Context, ::var"#152#156"{var"#loss#155", Matrix{Float64}})
      @ Zygote ~/.julia/packages/Zygote/bJn8I/src/compiler/interface2.jl:0
   [21] pullback(f::Function, ps::Zygote.Params)
      @ Zygote ~/.julia/packages/Zygote/bJn8I/src/compiler/interface.jl:351
   [22] gradient(f::Function, args::Zygote.Params)
      @ Zygote ~/.julia/packages/Zygote/bJn8I/src/compiler/interface.jl:75
   [23] top-level scope
      @ In[198]:36
   [24] eval
      @ ./boot.jl:373 [inlined]
   [25] include_string(mapexpr::typeof(REPL.softscope), mod::Module, code::String, filename::String)
      @ Base ./loading.jl:1196

而我曾期望它们的行为相同(至少在隔离时)。我自己无法从堆栈跟踪中获知多少信息,因为我没有实施 Flux.jlZygote.jl 的经验。但是问题似乎与函数 f 的定义有关,因为将 f 的定义更改为:

function f(x)
    a, b = p
    x*a + b
end

允许 letletless 版本工作。当然,我可以像这样修复它并收工。但我很好奇是否有人知道 为什么 这两个版本的工作方式不同?

备注

(@v1.7) pkg> status Flux
      Status `~/.julia/environments/v1.7/Project.toml`
  [587475ba] Flux v0.12.8

这很奇怪。所以主要区别在于第一个版本在全局范围内,第二个版本在本地范围内(let 块)。对于可重复的局部作用域,我们可以将代码放在一个函数中,或者更确切地说,只是足够的代码来查看问题:

function g()
  p = [1.0, 10.0] # for repeatability
  function f(x)
      f, b = p
      x*f + b
  end
  println(f)
  println(f(1))
  println(f)
  println(f(2))
end

结果:

julia> g()
f
11.0
1.0
ERROR: MethodError: objects of type Float64 are not callable

所以 f 作为一个函数开始,它的第一个调用就完成了它的工作 (11.0)。但是该调用将 p[1] 重新分配给 f,因此在第二次调用时,调用 f 或 1.0 失败。

我认为这源于 Julia 的 scoping rules

在全局作用域版本中,函数名f是全局的,恰好使用了一个局部变量f。完整的规则在 link 中,值得一读,但请放心,在特定局部范围内分配局部变量不会影响全局范围或其他孤立的局部范围内的任何内容。

在 let 块版本中,所有内容都嵌套在一个局部范围内。函数名f和函数变量f都是局部的。当您分配给已存在于当前或封闭局部作用域中的局部变量时,将使用现有变量。只是定义 f(x) 并没有立即 运行 f, b = p,但是在调用 f(1) 时,它 运行 并重新分配了本地 f.

在查看作用域规则之前,我自己并没有想到可以重新分配一个函数名称,更不用说通过它自己的调用了。我假设方法定义 only applies to global scope 的隐式 const。局部函数名称与任何其他局部变量没有什么不同,它们通常在嵌套的局部作用域(如 for 循环、理解和函数体)中重新分配。

正如您所演示的那样,更改变量名称可以避免这种局部重用,但您也可以在嵌套的局部作用域中显式创建一个新的局部变量,与封闭的局部作用域中可能存在的任何变量分开:

  function f(x)
      local f
      f, b = p
      x*f + b
  end

P.S。尝试以比文档更容易理解的顺序解释范围规则,尽管仍然很长。

  1. 全局范围包含全局变量,存在于文件级别或 module 块中。一个全局作用域可以包含多个独立的局部作用域,这些局部作用域由除 ifbegin 之外的任何其他块创建。任何局部作用域都包含其局部变量,并且可以包含其他局部作用域。

  2. 如果作用域 A 包含作用域 B,则 A 是一个 enclosing/outer 作用域,相对于 B 作为 nested/inner 作用域。如果范围 B 包含范围 C,则范围 A 也包含范围 C。

  3. 如果嵌套作用域没有分配特定名称的变量,它可以使用任何封闭作用域中同名的现有变量。

  4. 如果嵌套作用域分配一个变量,并且没有封闭作用域具有同名变量,则会为该嵌套作用域创建一个新变量。

  5. 如果嵌套作用域分配了一个变量,并且封闭的 local 作用域有一个预先存在的同名变量,那么封闭局部作用域的变量将被重新分配, 默认。简单的例子是my_sum = 0; for i in 1:3 my_sum += i end。要为该嵌套范围创建新变量,请在嵌套局部范围中使用 local <name> 语句。

  6. 如果嵌套作用域分配一个变量,并且只有 全局作用域有一个预先存在的同名变量,然后创建一个新的局部变量,默认。这种差异是因为与本地作用域不同,全局作用域可以分散在多个 included 文件中。没有人愿意仔细研究分散的文件以防止一个文件的代码意外地重新分配另一个文件的全局变量。要重新分配全局变量,请在嵌套的局部范围内使用 global <name> 语句。

  7. 请注意,循环和理解的每次迭代都会使用自己的局部变量创建自己的局部范围,尽管它们共享封闭范围的变量。文档给出 example of anonymous functions capturing iteration-local variables: for j = 1:2 Fs[j] = ()->j end; note that if the rule was different so j is the same variable across iterations (like in Python),然后所有匿名函数 return 相同的 j 值,不是很有用。

  8. 在 v1.0 的某一时刻,他们试图使交互式和非交互式范围规则保持一致,但人们抱怨说他们错过了将本地范围代码粘贴到 REPL 或笔记本的全局范围中.所以 some 块被指定为“软”局部作用域,并且在交互上下文中,默认情况下,软作用域重新分配全局变量。在非交互式上下文(.jl 文件、eval())中,软范围的行为与硬范围类似,但会打印警告。 hard/soft 的东西确实使事情变得复杂,所以在应用这个角度之前尝试理解其他规则。