Verso - это open-source платформа интерактивных ноутбуков и embeddable .NET execution engine для C#, F#, PowerShell, Python, SQL, JavaScript, TypeScript, HTTP, HTML, Markdown и Mermaid. Контекст важен: dotnet/interactive теперь архивирован, в README указано, что Polyglot Notebooks deprecated 27 марта 2026, .NET Interactive deprecated 24 апреля 2026, а сам репозиторий был архивирован 27 апреля 2026. Если коротко, .NET Interactive был привычным multi-language notebook engine, а Verso становится новой активно развиваемой заменой с более чистой extension model, VS Code и browser front ends, notebook/dashboard layouts и полноценными language kernels. Я тоже отправил туда пару PR-ов. Цель простая: сделать PowerShell first-class citizen в современных notebook workflows.

В этой статье я разбираю samples/Notebooks/powershell/micrograd/micrograd-ps.verso, ноутбук Verso, который переносит основные идеи из micrograd Андрея Карпати в PowerShell. Оригинальный проект нарочно маленький: scalar-valued reverse-mode automatic differentiation, а сверху небольшая neural-network library. Видео Карпати The spelled-out intro to neural networks and backpropagation: building micrograd до сих пор одно из лучших объяснений backpropagation, потому что оно не прячет граф вычислений. PowerShell-версия сохраняет эту идею, но использует eosfor/PSGraph и eosfor/PSGraphView, чтобы рисовать вычислительный граф прямо из объектов, созданных в ноутбуке.

Observable здесь тоже работает не просто как статический блог. SVG ниже не являются скриншотами, заранее положенными в репозиторий. Их генерируют Observable Framework file loaders, например scalar-graph.svg.ps1: во время сборки Observable запускает PowerShell, импортирует графовые модули, dot-source-ит те же скрипты, что использует Verso notebook, экспортирует Graphviz SVG через PSGraphView и встраивает результат в страницу.

Подготовка ноутбука

Ноутбук начинается с обычного импорта модулей. Никакой локальной сборки Verso для этого примера не нужно:

Import-Module PSQuickGraph
Import-Module PSGraphView

Реализация разбита на четыре небольших скрипта:

В ноутбуке они подключаются напрямую:

. ./value.ps1
. ./graphHelper.ps1
. ./neuronHelper.ps1
. ./helpers.ps1

Главный класс - Value. Каждый объект хранит data, grad, label, операцию, которая его создала, дочерние значения и backward closure. В этом весь фокус: обычная арифметика возвращает не только результат, но и маленький кусок локальной логики для производной.

Для сложения производная равна единице по обоим входам:

static [Value] op_Addition([Value]$left, [Value]$right) {
    $out = [Value]::new($left.data + $right.data, @($left, $right), "+", "+_res")

    $out.backward = {
        $left.grad += 1 * $out.grad
        $right.grad += 1 * $out.grad
    }.GetNewClosure()

    return $out
}

Для умножения каждый вход получает значение другого входа, умноженное на gradient результата:

static [Value] op_Multiply([Value]$left, [Value]$right) {
    $out = [Value]::new($left.data * $right.data, @($left, $right), "*", "*_res")

    $out.backward = {
        $left.grad += $right.data * $out.grad
        $right.grad += $left.data * $out.grad
    }.GetNewClosure()

    return $out
}

Tanh() устроен так же, только derivative равен 1 - tanh(x)^2:

[Value] Tanh(){
    $v = $this
    $t = [Math]::Tanh($this.data)
    $out = [Value]::new($t, @($this), "tanh")

    $out.backward = {
        $v.grad += (1 - [Math]::Pow($t, 2)) * $out.grad
    }.GetNewClosure()

    return $out
}

Scalar Computation Graph

Первый пример в ноутбуке - скалярное выражение в стиле micrograd:

$a = [Value]::new( 2.0, 'a')
$b = [Value]::new(-3.0, 'b')
$c = [Value]::new(10.0, 'c')
$e = $a * $b; $e.label = 'e'
$d = $e + $c; $d.label = 'd'
$f = [Value]::new(-2.0, 'f')
$L = $d * $f; $L.label = 'L'

На этом этапе $L.data равно -8, а все gradients еще нулевые. Граф строится от выходного значения:

$scalarGraph = New-ExpressionGraph -val $L
Show-ExpressionGraph -Graph $scalarGraph

New-ExpressionGraph идет от output node назад по children. Для значений создаются record-shaped nodes, для операций - ellipse-shaped nodes. Так как Value objects являются настоящими object references, вспомогательные hashtables не дают создать дубликаты вершин, если один и тот же объект встречается в графе повторно.

Порядок backpropagation

Backpropagation выполняется не по display graph. Ноутбук строит второй граф, уже прямо из оригинальных объектов Value:

$bpGraph = New-BackpropagationGraph -val $L
$L.grad = 1.0

Get-GraphTopologicalSort -Graph $bpGraph -Reverse |
    ForEach-Object { $_.OriginalObject } |
    ForEach-Object { & $_.backward }

Gradient у output начинается с 1.0, потому что dL/dL = 1. Затем Get-GraphTopologicalSort -Reverse посещает output первым и идет назад к leaf nodes. Каждый узел выполняет closure, который был захвачен в момент создания значения. После прохода display graph пересобирается, чтобы в ноды попал свежий snapshot grad.

Это ключевая деталь реализации: граф здесь не только картинка. Это dependency structure, по которой выполняется reverse-mode autodiff.

Один нейрон

Следующая ячейка руками собирает маленький нейрон: два входа, два веса, bias и tanh activation.

$x1 = [Value]::new(2.0, 'x1')
$x2 = [Value]::new(0.0, 'x2')

$w1 = [Value]::new(-3.0, 'w1')
$w2 = [Value]::new(1.0, 'w2')
$b = [Value]::new(6.8813735870195432, 'b')

$x1w1 = $x1 * $w1; $x1w1.label = 'x1*w1'
$x2w2 = $x2 * $w2; $x2w2.label = 'x2*w2'
$x1w1x2w2 = $x1w1 + $x2w2; $x1w1x2w2.label = 'x1*w1 + x2*w2'
$n = $x1w1x2w2 + $b; $n.label = 'n'
$o = $n.Tanh(); $o.label = 'o'

Тот же topological backward pass от $o заполняет gradients для входов, весов, bias и промежуточных значений:

$bpNeuronGraph = New-BackpropagationGraph -val $o
$o.grad = 1.0

Get-GraphTopologicalSort -Graph $bpNeuronGraph -Reverse |
    ForEach-Object { $_.OriginalObject } |
    ForEach-Object { & $_.backward }

В этот момент ноутбук становится особенно полезным как учебный инструмент. Можно посмотреть каждый scalar contribution к нейрону, а не воспринимать нейрон как черный ящик.

Layer и MLP

После ручного нейрона neuronHelper.ps1 заворачивает ту же логику в классы. Neuron хранит массив weights и bias:

class Neuron {
    [Value[]]$w
    [Value]$b

    Neuron([int]$nin) {
        $this.w = for ($i = 0; $i -lt $nin; $i++) {
            [Value]::new(([Random]::Shared.NextDouble() * 2 - 1), "w$i")
        }

        $this.b = [Value]::new(([Random]::Shared.NextDouble() * 2 - 1), "b")
    }

    [Value] Invoke([Value[]]$x) {
        $sum = $this.b
        for ($i = 0; $i -lt $this.w.Count; $i++) {
            $sum = $sum + ($this.w[$i] * $x[$i])
        }

        return $sum.Tanh()
    }
}

Layer применяет несколько нейронов к одному input vector. MLP связывает layers так, что каждый следующий layer получает output vector предыдущего:

$x = @(
    [Value]::new(2.0, 'x1')
    [Value]::new(3.0, 'x2')
    [Value]::new(-1.0, 'x3')
)

$layer = [Layer]::new(3, 4)
$layer.Invoke($x)

$net = [MLP]::new(3, @(4, 4, 1))
$res = $net.Invoke($x)
$res

В ноутбуке можно отрисовать и полный expression graph для MLP:

$netGraph = New-ExpressionGraph -val $res[0]
Show-ExpressionGraph -Graph $netGraph -rankdir 'TD'

Я не встраиваю его в статью намеренно: он уже достаточно широкий, чтобы быть неудобным для чтения в блоге. Маленькие графы scalar expression и neuron лучше показывают механику.

Training Data и Loss

Training set взят из micrograd walkthrough:

$xs = @(
    @([Value]::new(2.0, 'x11'), [Value]::new( 3.0, 'x12'), [Value]::new(-1.0, 'x13')),
    @([Value]::new(3.0, 'x21'), [Value]::new(-1.0, 'x22'), [Value]::new( 0.5, 'x23')),
    @([Value]::new(0.5, 'x31'), [Value]::new( 1.0, 'x32'), [Value]::new( 1.0, 'x33')),
    @([Value]::new(1.0, 'x41'), [Value]::new( 1.0, 'x42'), [Value]::new(-1.0, 'x43'))
)

$ys = @(
    [Value]::new( 1.0, 'y1'),
    [Value]::new(-1.0, 'y2'),
    [Value]::new(-1.0, 'y3'),
    [Value]::new( 1.0, 'y4')
)

Loss - это sum of squared errors:

$net = [MLP]::new(3, @(4, 4, 1))

$ypred = $xs | ForEach-Object { $net.Invoke($_)[0] }
$loss = Zip -Left $ys -Right $ypred | Sum-Value {
    $diff = $_.Right - $_.Left
    $diff * $diff
}

Zip соединяет expected и predicted values. Sum-Value стартует с Value по имени loss и добавляет выбранные terms. Так как каждое вычитание, умножение и сложение возвращает новый Value, loss тоже становится scalar root полного computation graph.

Один training step

Один optimization step выглядит почти как PyTorch, только без скрытой магии:

foreach ($p in $net.parameters()) {
    $p.grad = 0.0
}
foreach ($row in $xs) {
    foreach ($v in $row) { $v.grad = 0.0 }
}
foreach ($y in $ys) {
    $y.grad = 0.0
}

$ypred = $xs | ForEach-Object { $net.Invoke($_)[0] }
$loss = Zip -Left $ys -Right $ypred | Sum-Value {
    $diff = $_.Right - $_.Left
    $diff * $diff
}

$loss.grad = 1.0
$bpLossGraph = New-BackpropagationGraph -val $loss

Get-GraphTopologicalSort -Graph $bpLossGraph -Reverse |
    ForEach-Object { $_.OriginalObject } |
    ForEach-Object { & $_.backward }

foreach ($p in $net.parameters()) {
    $p.data += -0.1 * $p.grad
}

Здесь пять фаз: очистить gradients, выполнить forward pass, построить loss, выполнить backward pass, обновить parameters. Learning rate жестко задан как 0.1, потому что это demo notebook, а не training framework.

Training loop

В ноутбуке этот шаг повторяется 200 раз. Для блога я использую еще один Observable PowerShell loader, loss-history.csv.ps1: он запускает короткий training loop и возвращает CSV. Observable читает CSV и строит график loss.

Последняя ячейка ноутбука рисует полный loss graph после обучения:

$lossGraph = New-ExpressionGraph -val $loss
Show-ExpressionGraph -Graph $lossGraph -rankdir 'TD'

Это полезный stress test для PSGraphView, но для страницы он слишком большой: внутри находится полный scalar computation, который привел к loss. И в этом как раз смысл micrograd: neural network можно понимать как большое скалярное выражение, а backpropagation - как аккуратный reverse walk по этому выражению.

Зачем это нужно

Главная мысль не в том, что PowerShell является лучшим языком для neural networks. Не является. Смысл в другом: Verso возвращает PowerShell notebooks в нормальное состояние после окончания .NET Interactive, а PowerShell kernel теперь умеет то, чего пользователи ждут от notebook experience: long-running host output, cancellation, persistent state, rich display и обычные workflows вокруг модулей.

Для infrastructure engineers это важно. Те же механики, которые здесь используются для micrograd graphs, применимы к dependency graphs, Azure topology, policy validation, incident analysis и любому другому сценарию, где PowerShell производит structured objects, а notebook должен сделать эти объекты видимыми.