Compartilhar via


Expressões BrainScript

Esta seção é a especificação de expressões BrainScript, embora usemos intencionalmente uma linguagem informal para mantê-la legível e acessível. Seu equivalente é a especificação da sintaxe de definição de função Do BrainScript, que pode ser encontrada aqui.

Cada script cerebral é uma expressão, que, por sua vez, consiste em expressões atribuídas a variáveis de membro de registro. O nível mais externo de uma descrição de rede é uma expressão de registro implícita. O BrainScript tem os seguintes tipos de expressões:

  • literais como números e cadeias de caracteres
  • infixo de matemática e operações unárias como a + b
  • uma expressão condicional ternária
  • invocações de função
  • registros, acessos de membro de registro
  • matrizes, acessos de elemento de matriz
  • expressões de função (lambdas)
  • Construção de objeto C++ interno

Nós intencionalmente mantivemos a sintaxe para cada um deles o mais próximo possível de idiomas populares, tanto do que você encontra abaixo parecerá muito familiar.

Conceitos

Antes de descrever os tipos individuais de expressão, primeiro alguns conceitos básicos.

Computação Imediata vs. Adiada

O BrainScript conhece dois tipos de valores: imediato e adiado. Os valores imediatos são calculados durante o processamento do BrainScript, enquanto os valores adiados são objetos que representam nós na rede de computação. A rede de computação descreve a computação real executada pelo mecanismo de execução CNTK durante o treinamento e o uso do modelo.

Os valores imediatos no BrainScript destinam-se a parametrizar a computação. Eles denotam dimensões tensores, o número de camadas de rede, um nome de caminho para carregar um modelo, etc. Como as variáveis BrainScript são imutáveis, valores imediatos são sempre constantes.

Os valores adiados surgem da finalidade primária dos scripts cerebrais: descrever a rede de computação. A rede de computação pode ser vista como uma função que é passada para a rotina de treinamento ou inferência, que executa a função de rede por meio do mecanismo de execução CNTK. Portanto, o resultado de muitas expressões BrainScript é um nó de computação em uma rede de computação, em vez de um valor real. Do ponto de vista do BrainScript, um valor adiado é um objeto C++ do tipo ComputationNode que representa um nó de rede. Por exemplo, a soma de dois nós de rede cria um novo nó de rede que representa a operação de resumo que usa os dois nós como entradas.

Escalares vs. Matrizes vs. Tensors

Todos os valores na rede de computação são matrizes ndimensionais numéricas que chamamos de tensores e n indica a classificação tensor. As dimensões tensor são especificadas explicitamente para entradas e parâmetros de modelo; e inferidos automaticamente pelos operadores.

O tipo de dados mais comum para computação, matrizes, são apenas tensores da classificação 2. Vetores de coluna são tensores da classificação 1, enquanto os vetores de linha são classificados como 2. O produto de matriz é uma operação comum em redes neurais.

Tensores são sempre valores adiados, ou seja, objetos no grafo de computação adiado. Qualquer operação que envolva uma matriz ou tensor se torna parte do grafo de computação e é avaliada durante o treinamento e a inferência. As dimensões tensor, no entanto, são inferidas/verificadas antecipadamente no tempo de processamento do BS.

Escalares podem ser valores imediatos ou adiados. Escalares que parametrizam a própria rede de computação, como dimensões tensores, devem ser imediatos, ou seja, computáveis no momento do processamento do BrainScript. Escalares adiados são tensores de classificação 1 da dimensão [1]. Elas fazem parte da própria rede, incluindo parâmetros escalares aprendizes, como auto-estabilizadores e constantes como em Log (Constant (1) + Exp(x)).

Digitação dinâmica

O BrainScript é uma linguagem de tipo dinâmico com um sistema de tipo extremamente simples. Os tipos são verificados durante o processamento do BrainScript quando um valor é usado.

Os valores imediatos são de número de tipo, booliano, cadeia de caracteres, registro, matriz, função/lambda ou uma das classes C++ predefinidas do CNTK. Seus tipos são verificados no momento do uso (por exemplo, o COND argumento para a if instrução é verificado como um Boolean, e um acesso de elemento de matriz requer que o objeto seja uma matriz).

Todos os valores adiados são tensores. As dimensões tensor fazem parte de seu tipo, que são verificadas ou inferidas durante o processamento do BrainScript.

Expressões entre um escalar imediato e um tensor adiado devem converter explicitamente o escalar em um adiado Constant(). Por exemplo, a não linearidade softplus deve ser escrita como Log (Constant(1) + Exp (x)). (Está planejado remover esse requisito em uma atualização futura.)

Tipos de expressão

Literais

Literais são constantes numéricas, boolianas ou de cadeia de caracteres, como seria de esperar. Exemplos:

  • 13, 42, 3.1415926538, 1e30
  • true, false
  • "my_model.dnn", 'single quotes'

Literais numéricos são sempre ponto flutuante de precisão dupla. Não há nenhum tipo inteiro explícito no BrainScript, embora algumas expressões, como índices de matriz, falhem com um erro se forem apresentados valores que não são inteiros.

Literais de cadeia de caracteres podem usar aspas simples ou duplas, mas não têm como escapar as aspas ou outros caracteres dentro (cadeia de caracteres contendo aspas simples e duplas deve ser computada, por exemplo "He'd say " + '"Yes!" in a jiffy.'). Literais de cadeia de caracteres podem abranger várias linhas; por exemplo:

I3 = Parameter (3, 3, init='fromLiteral', initFromLiteral = '1 0 0
                                                             0 1 0
                                                             0 0 1')

Operações Infix e Unary

O BrainScript dá suporte aos operadores abaixo. Operadores BrainScript são escolhidos para significar o que se esperaria proveniente de linguagens populares, com exceção de (produto em termos de .* elemento), * (produto de matriz) e semântica de difusão especial de operações em termos de elemento.

Operadores +de infixo numérico, -, *, , /.*

  • +, -e * aplicar a escalares, matrizes e tensores.
  • .* denota um produto em termos de elemento. Observação para usuários do Python: isso é o equivalente a numpy.*
  • / só há suporte para escalares. Uma divisão em termos de elemento pode ser escrita usando computação interna Reciprocal(x) um elemento-wise 1/x.

Operadores boolianos &&de infixo, ||

Estes denotam BOOLEAN AND e OR, respectivamente.

Concatenação de cadeia de caracteres (+)

Cadeias de caracteres são concatenadas com +. Exemplo: BS.Networks.Load (dir + "/model.dnn").

Operadores de comparação

Os seis operadores de comparação são<, ==e >suas negações>=, !=. <= Eles podem ser aplicados a todos os valores imediatos conforme o esperado; seu resultado é um booliano.

Para aplicar operadores de comparação a tensores, é necessário usar funções internas, como Greater().

Unary-, !

Esses demonstram negação e negação lógica, respectivamente. ! atualmente só pode ser usado para escalares.

Operações elementais e semântica de difusão

Quando aplicado a matrizes/tensores, +-e .* são aplicados em termos de elemento.

Todas as operações em termos de elemento dão suporte à semântica de difusão. A difusão significa que qualquer dimensão especificada como 1 será repetida automaticamente para corresponder a qualquer dimensão.

Por exemplo, um vetor de linha de dimensão [1 x N] pode ser adicionado diretamente a uma matriz de dimensão [M x N]. A 1 dimensão repetirá automaticamente M os tempos. Além disso, as dimensões tensores são adicionadas automaticamente com 1 dimensões. Por exemplo, é permitido adicionar um vetor de coluna da dimensão [M] de uma [M x N] matriz. Nesse caso, as dimensões do vetor de coluna são adicionadas automaticamente para corresponder à [M x 1] classificação da matriz.

Observação aos usuários do Python: ao contrário do numpy, as dimensões de difusão são alinhadas à esquerda.

O operador Matrix-Product*

A A * B operação denota o produto de matriz. Ele também pode ser aplicado a matrizes esparsas, o que melhora a eficiência para o tratamento de entradas de texto ou rótulos que são representados como vetores únicos. Em CNTK, o produto de matriz tem uma interpretação estendida que permite que ele seja usado com tensores da classificação > 2. É possível, por exemplo, multiplicar cada coluna em um tensor de classificação 3 individualmente com uma matriz.

O produto de matriz e sua extensão tensor são descritos em detalhes aqui.

Observação: para multiplicar com um escalar, use o produto .*em termos de elemento.

Os usuários do Python devem ser avisados de que numpy usa o * operador para o produto em termos de elemento , não o produto de matriz. O operador do * CNTK corresponde a numpy'sdot(), enquanto CNTK é equivalente ao operador do * Python para numpy matrizes é .*.

O operador condicional if

As condicionais no BrainScript são expressões, como o operador C++ ? . A sintaxe BrainScript é if COND then TVAL else EVAL, onde COND deve ser uma expressão booliana imediata, e o resultado da expressão é se COND for TVAL verdadeiro, e EVAL de outra forma. A if expressão é útil para implementar várias configurações semelhantes com sinalizador parametrizado no mesmo BrainScript e também para recursão.

(O if operador funciona apenas para valores escalares imediatos. Para implementar condicionalidades para objetos adiados, use a função BS.Boolean.If()interna, que permite selecionar um valor de um dos dois tensores com base em um tensor de sinalizador. Ele tem o formulário If (cond, tval, eval).)

Invocações de Função

O BrainScript tem três tipos de funções: primitivos internos (com implementações C++), funções de biblioteca (escritas em BrainScript) e definidas pelo usuário (BrainScript). Exemplos de funções internas são Sigmoid() e MaxPooling(). As funções definidas pelo usuário e biblioteca são mecanicamente iguais, apenas salvas em arquivos de origem diferentes. Todos os tipos são invocados, de forma semelhante à matemática e linguagens comuns, usando o formulário f (arg1, arg2, ...).

Algumas funções aceitam parâmetros opcionais. Parâmetros opcionais são passados como parâmetros nomeados, por exemplo f (arg1, arg2, option1=..., option2=...).

As funções podem ser invocadas recursivamente, por exemplo:

DNNLayerStack (x, numLayers) =
    if numLayers == 1
    then DNNLayer (x, hiddenDim, featDim)
    else DNNLayer (DNNLayerStack (x, numLayers-1), # add a layer to a stack of numLayers-1
                   hiddenDim, hiddenDim)

Observe como o if operador é usado para encerrar a recursão.

Criação de camada

As funções podem criar camadas ou modelos inteiros que são objetos de função que também se comportam como funções. Por convenção, uma função que cria uma camada com parâmetros aprendizes usa chaves { } em vez de parênteses ( ). Você encontrará expressões como esta:

h = DenseLayer {1024} (v)

Aqui, duas invocações estão em jogo. A primeira, DenseLayer{1024}é uma chamada de função que cria um objeto de função, que, por sua vez, é aplicado aos dados (v). Como DenseLayer{} retorna um objeto de função com parâmetros de aprendizado, ele usa { } para indicar isso.

Registros e acesso Record-Member

Expressões de registro são atribuições cercadas por chaves. Por exemplo:

{
    x = 13
    y = x * factorParameter
    f (z) = y + z
}

Essa expressão define um registro com três membros, xyef, onde f está uma função. Dentro do registro, as expressões podem referenciar outros membros de registro apenas pelo nome, como x é acessado acima na atribuição de y.

No entanto, ao contrário de muitos idiomas, as entradas de registro podem ser declaradas em qualquer ordem. Por exemplo, x pode ser declarado após y. Isso é para facilitar a definição de redes recorrentes. Qualquer membro de registro pode ser acessado por meio da expressão de qualquer outro membro de registro. Isso é diferente de, digamos, Python; e semelhante a F#'s let rec. Referências cíclicas são proibidas, com exceção especial das operações e FutureValue() das PastValue() operações.

Quando os registros são aninhados (expressões de registro usadas dentro de outros registros), os membros de registro são pesquisados por toda a hierarquia de escopos de colocação. Na verdade, cada atribuição de variável faz parte de um registro: o nível externo de um BrainScript também é um registro implícito. No exemplo acima, factorParameter teria que ser atribuído como um membro de registro de um escopo de cercamento.

As funções atribuídas dentro de um registro capturarão os membros de registro referenciados. Por exemplo, f() capturará y, o que, por sua vez, depende x e o externamente definido factorParameter. Capturar esses meios que f() podem ser passados como um lambda para escopos externos que não contêm factorParameter ou têm acesso a ele.

De fora, os membros de registro são acessados usando o . operador. Por exemplo, se tivéssemos atribuído a expressão de registro acima a uma variável r, produziria r.x o valor 13. O . operador não atravessa escopos de abrasão: r.factorParameter falharia com um erro.

(Observe que até CNTK 1.6, em vez de chaves{ ... }, registros usados colchetes [ ... ]. Isso ainda é permitido, mas preterido.)

Matrizes e Acesso à Matriz

O BrainScript tem um tipo de matriz unidimensional para valores imediatos (para não ser confundido com tensores). Matrizes são indexadas usando [index]. Matrizes multidimensionais podem ser emuladas como matrizes de matrizes.

Matrizes de pelo menos 2 elementos podem ser declaradas usando o : operador. Por exemplo, o seguinte declara uma matriz tridimensional chamada imageDims que, em seguida, é passada para ParameterTensor{} declarar um tensor de parâmetro rank-3:

imageDims = (256 : 256 : 3)
inputFilter = ParameterTensor {imageDims}

Também é possível declarar matrizes cujos valores fazem referência uns aos outros. Para isso, é necessário usar a sintaxe de atribuição de matriz um pouco mais envolvida:

arr[i:i0..i1] = f(i)

que constrói uma matriz nomeada arr com limite de índice i0 inferior e limite i1de índice superior, i indicando a variável para indicar a variável de índice na expressãof(i) inicializador, que por sua vez denota o valor de arr[i]. Os valores da matriz são avaliados de forma preguiçosa. Isso permite que a expressão inicializador de um índice i específico acesse outros elementos arr[j] da mesma matriz, desde que não haja dependência cíclica. Por exemplo, isso pode ser usado para declarar uma pilha de camadas de rede:

layers[l:1..L] =
    if l == 1
    then DNNLayer (x, hiddenDim, featDim)
    else DNNLayer (layers[l-1], hiddenDim, hiddenDim)

Ao contrário da versão recursiva disso que introduzimos anteriormente, essa versão preserva o acesso a cada camada individual dizendo layers[i].

Como alternativa, há também uma sintaxe array[i0..i1] (i => f(i))de expressão, que é menos conveniente, mas às vezes útil. O acima teria esta aparência:

layers = array[1..L] (l =>
    if l == 1
    then DNNLayer (x, hiddenDim, featDim)
    else DNNLayer (layers[l-1], hiddenDim, hiddenDim)
)

Observação: atualmente não há como declarar uma matriz de 0 elementos. Isso será abordado em uma versão futura do CNTK.

Expressões de funções e lambdas

No BrainScript, as funções são valores. Uma função nomeada pode ser atribuída a uma variável e passada como um argumento, por exemplo:

Layer (x, m, n, f) = f (ParameterTensor {(m:n)} * x + ParameterTensor {n})
h = Layer (x, 512, 40, Sigmoid)

onde Sigmoid é passado como uma função que é usada dentro Layer(). Como alternativa, uma sintaxe (x => f(x)) lambda semelhante a C#permite criar funções anônimas embutidas. Por exemplo, isso define uma camada de rede com uma ativação softplus:

h = Layer (x, 512, 40, (x => Log (Constant(1) + Exp (x)))

A sintaxe lambda está atualmente limitada a funções com um único parâmetro.

Padrão de camada

O exemplo acima Layer() combina a criação de parâmetros e o aplicativo de funções. Um padrão preferencial é separá-los em duas etapas:

  • criar parâmetros e retornar um objeto de função que contém esses parâmetros
  • criar a função que aplica os parâmetros a uma entrada

Especificamente, este último também é um membro do objeto de função. O exemplo acima pode ser reescrito como:

Layer {m, n, f} = {
    W = ParameterTensor {(m:n)}  # parameter creation
    b = ParameterTensor {n}
    apply (x) = f (W * x + b)    # the function to apply to data
}.apply

e seria invocado como:

h = Layer {512, 40, Sigmoid} (x)

O motivo para esse padrão é que os tipos de rede típicos consistem em aplicar uma função após a outra a uma entrada, que pode ser gravada com mais facilidade usando a Sequential() função.

CNTK vem com um rico conjunto de camadas predefinidas, que são descritas aqui.

Construindo objetos C++ CNTK internos

Em última análise, todos os valores do BrainScript são objetos C++. O operador new BrainScript especial é usado para interfiguração com os objetos CNTK C++ subjacentes. Ele tem o formulário new TYPE ARGRECORD em TYPE que é um de um conjunto codificado em código dos objetos C++ predefinidos expostos ao BrainScript e ARGRECORD é uma expressão de registro que é passada para o construtor C++.

Provavelmente, você só poderá ver esse formulário se estiver usando a forma de parêntese de BrainScriptNetworkBuilder, ou seja BrainScriptNetworkBuilder = (new ComputationNetwork { ... }), conforme descrito aqui. Mas agora você sabe o que significa: new ComputationNetwork cria um novo objeto C++ do tipoComputationNetwork, em { ... } que simplesmente define um registro que é passado para o construtor C++ do objeto C++ internoComputationNetwork, que procurará cinco membros específicosfeatureNodes, labelNodeseoutputNodescriterionNodesevaluationNodes, como explicado aqui.

Sob o capô, todas as funções internas são realmente new expressões que constroem objetos da classe ComputationNodeCNTK C++. Para ilustração, veja como o Tanh() interno é realmente definido como a criação de um objeto C++:

Tanh (z, tag=') = new ComputationNode { operation = 'Tanh' ; inputs = z /plus the function args/}

Semântica de Avaliação de Expressão

As expressões BrainScript são avaliadas após o primeiro uso. Como a principal finalidade do BrainScript é descrever a rede, o valor de uma expressão geralmente é um nó em um grafo de computação para computação adiada. Por exemplo, do ângulo brainscript, W1 * r + b1 no exemplo acima 'avalia' para um ComputationNode objeto em vez de um valor numérico; enquanto os valores numéricos reais envolvidos serão calculados pelo mecanismo de execução de grafo. Somente expressões BrainScript de escalares (por exemplo 28*28) são "computadas" no momento em que o BrainScript é analisado. Expressões que nunca são usadas (por exemplo, devido a uma condição) nunca são avaliadas (nem verificadas se há erros de tipo).

Padrões comuns de uso de expressões

Abaixo estão alguns padrões comuns usados com o BrainScript.

Namespaces para Funções

Agrupando atribuições de função em registros, é possível obter uma forma de espaçamento de nomes. Por exemplo:

Layers = {
    Affine (x, m, n) = ParameterTensor {(m:n)} * x + ParameterTensor {n}
    Sigmoid (x, m, n) = Sigmoid (Affine (x, m, n))
    ReLU (x, m, n) = RectifiedLinear (Affine (x, m, n))
}
# 1-hidden layer MLP
ce = CrossEntropyWithSoftmax (Layers.Affine (Layers.Sigmoid (feat, 512, 40), 9000, 512))

Variáveis com escopo local

Às vezes, é desejável ter variáveis com escopo local e/ou funções para expressões mais complexas. Isso pode ser obtido colocando toda a expressão em um registro e acessando imediatamente seu valor de resultado. Por exemplo:

{ x = 13 ; y = x * x }.y

criará um registro 'temporário' com um membro y que é lido imediatamente. Esse registro é 'temporário', pois não é atribuído a uma variável e, portanto, seus membros não são acessíveis, exceto por y.

Esse padrão geralmente é usado para tornar as camadas NN com parâmetros internos mais legíveis, por exemplo:

SigmoidLayer (m, n, x) = {
    W = Parameter (m, n, init='uniform')
    b = Parameter (m, 1, init='value', initValue=0)
    h = Sigmoid (W * x + b)
}.h

Aqui, h pode-se pensar no "valor retornado" dessa função.

A seguir: saiba mais sobre como definir funções do BrainScript

NDLNetworkBuilder (preterido)

Versões anteriores do CNTK usaram o agora preterido NDLNetworkBuilder em vez de BrainScriptNetworkBuilder. NDLNetworkBuilder implementou uma versão muito reduzida do BrainScript. Ele tinha as seguintes restrições:

  • Sem sintaxe de infixo. Todos os operadores devem ser invocados por meio de invocações de função. Por exemplo, Plus (Times (W1, r), b1) em vez de W1 * r + b1.
  • Nenhuma expressão de registro aninhado. Há apenas um registro externo implícito.
  • Nenhuma expressão condicional ou invocação de função recursiva.
  • As funções definidas pelo usuário devem ser declaradas em blocos especiais load e não podem aninhar.
  • A última atribuição de registro é usada automaticamente como o valor de uma função.
  • A NDLNetworkBuilder versão do idioma não está concluída por Turing.

NDLNetworkBuilder não deve mais ser usado.