Construire avec bricks
Blocks est un cadre d'application censé faciliter la construction de modèles de réseaux neuronaux complexes basés sur Theano. Pour ce faire, ils ont introduit le concept de «bricks», que vous avez peut-être déjà rencontré dans le tutoriel d'introduction <model_building>.
Cycle de vie des bricks
Blocks utilisent des «bricks» pour construire des modèles. Les bricks sont des opérations Theano paramétrées. Une brick est généralement définie par un ensemble d'attributs et un ensemble de paramètres. Les premiers spécifient les attributs qui définissent le bloc (par exemple, le nombre d'unités d'entrée et de sortie), les seconds représentent les paramètres de l'objet brique variant pendant l'apprentissage (par exemple, les pondérations et les biais).
Le cycle de vie d'une brick est le suivant :
- Configuration : définition (d'une partie) des attributs de brick. Peut avoir lieu lors de la création de l'objet brique, en définissant les paramètres du constructeur, ou ultérieurement, en définissant les attributs de l'objet brique. Aucune variable Theano n'est créée à cette phase.
- Allocation : (facultatif) allocation des variables partagées Theano pour les paramètres de brick. Lorsque :meth:`~.bricks.Brick.allocate` est appelé, les variables Theano requises sont allouées et initialisées par défaut à NaN.
- Application : instancier une partie du graphe de calcul Theano, reliant les entrées et les sorties de brick via ses paramètres et ses attributs. Cette opération est impossible (c'est-à-dire génère une erreur) si l'objet Brick n'est pas entièrement configuré.
- Initialisation : définir les valeurs numériques des variables Theano entreposant les paramètres de brick. La valeur fournie par l'utilisateur remplacera la valeur d'initialisation par défaut.
Remarque
Si les variables Theano de l'objet brick n'ont pas été allouées lors de l'appel de :meth:`~.Application.apply`, Blocks appellera discrètement :meth:`~.bricks.Brick.allocate`.
Exemple
Les bricks prennent des variables Theano en entrée et fournissent des variables Theano en sortie :
|
>>> import theano >>> from theano import tensor >>> from blocks.bricks import Tanh >>> x = tensor.vector('x') >>> y = Tanh().apply(x) >>> print(y) tanh_apply_output >>> isinstance(y, theano.Variable) True |
Il s'agit clairement d'un exemple artificiel, car cela semble une écriture compliquée de y = tenseur.tanh(x). Pour comprendre l'utilité de Blocks, considérons une tâche très courante lors de la création de réseaux de neurones : appliquer une transformation linéaire (avec biais optionnel) à un vecteur, puis initialiser la matrice de pondération et le vecteur de biais avec des valeurs issues d'une distribution particulière.
|
>>> from blocks.bricks import Linear >>> from blocks.initialization import IsotropicGaussian, Constant >>> linear = Linear(input_dim=10, output_dim=5, ... weights_init=IsotropicGaussian(), ... biases_init=Constant(0.01)) >>> y = linear.apply(x) |
Alors, que s'est-il passé ? Nous avons construit une brique appelée :class:`.Linear` avec une configuration particulière : la dimension d'entrée (10) et la dimension de sortie (5). Lorsque nous avons appelé :attr:`.Linear.apply`, la brick a automatiquement construit les variables Theano partagées nécessaires au stockage de ses paramètres. Dans le cycle de vie d'une brique, on parle d'allocation.
|
>>> linear.parameters [W, b] >>> linear.parameters[1].get_value() # doctest: +SKIP array([ nan, nan, nan, nan, nan]) |
Par défaut, tous nos paramètres sont définis sur NaN. Pour les initialiser, appelez simplement la méthode :meth:`~.bricks.Brick.initialize`. Il s'agit de la dernière étape du cycle de vie d'une brick : l'initialisation.
|
>>> linear.initialize() >>> linear.parameters[1].get_value() # doctest: +SKIP array([ 0.01, 0.01, 0.01, 0.01, 0.01]) |
Gardez à l'esprit qu'en fin de compte, les bricks servent uniquement à construire un graphe de calcul Theano ; il est donc possible d'y intégrer des instructions Theano classiques lors de la construction de modèles. (Cependant, vous risquez de manquer certaines fonctionnalités plus intéressantes des blocs, comme l'annotation de variables.)
| >>> z = tensor.max(y + 4) |
Initialisation différée
Dans l'exemple ci-dessus, nous avons configuré la brick :class:`.Linear` lors de l'initialisation. Nous avons spécifié les dimensions d'entrée et de sortie, ainsi que la manière dont les matrices de pondération doivent être initialisées. Prenons le cas suivant, assez courant : nous souhaitons utiliser la sortie d'un modèle comme entrée pour un autre modèle, mais les dimensions de sortie et d'entrée ne correspondent pas. Nous devons donc ajouter une transformation linéaire au milieu.
Pour prendre en charge ce cas d'utilisation, les briques permettent l'initialisation différée, activée par défaut. Cela signifie que vous pouvez créer une brique sans la configurer complètement (voire pas du tout) :
|
>>> linear2 = Linear(output_dim=10) >>> print(linear2.input_dim) NoneAllocation |
Bien sûr, tant que la brique n'est pas configurée, nous ne pouvons pas réellement l'appliquer !
|
>>> linear2.apply(x) Traceback (most recent call last): ... ValueError: allocation config not set: input_dim |
Nous pouvons désormais configurer facilement notre brique en fonction d'autres bricks.
|
>>> linear2.input_dim = linear.output_dim >>> linear2.apply(x) linear_apply_output |
Dans les exemples précédents, l'allocation des paramètres s'est toujours faite implicitement lors de l'appel des méthodes apply, mais elle peut également être appelée explicitement. Prenons l'exemple suivant :
|
>>> linear3 = Linear(input_dim=10, output_dim=5) >>> linear3.parameters Traceback (most recent call last): ... AttributeError: 'Linear' object has no attribute 'parameters' >>> linear3.allocate() >>> linear3.parameters [W, b] |
Bricks imbriquées
De nombreux modèles de réseaux neuronaux, notamment les plus complexes, peuvent être considérés comme des structures hiérarchiques. Même un simple perceptron multicouche est constitué de couches, elles-mêmes constituées d'une transformation linéaire suivie d'une transformation non linéaire.
De ce fait, les bricks peuvent avoir des enfants. Les bricks mères peuvent configurer leurs enfants, par exemple pour garantir la compatibilité de leurs configurations ou pour définir des valeurs par défaut adaptées à un cas d'utilisation particulier.
|
>>> from blocks.bricks import MLP, Logistic >>> mlp = MLP(activations=[Logistic(name='sigmoid_0'), ... Logistic(name='sigmoid_1')], dims=[16, 8, 4], ... weights_init=IsotropicGaussian(), biases_init=Constant(0.01)) >>> [child.name for child in mlp.children] ['linear_0', 'sigmoid_0', 'linear_1', 'sigmoid_1'] >>> y = mlp.apply(x) >>> mlp.children[0].input_dim 16 |
Nous pouvons constater que la brick :class:`~.bricks.MLP` a automatiquement construit deux bricks enfants pour effectuer les transformations linéaires. Lorsque nous avons appliqué la MLP à x, elle a automatiquement configuré les dimensions d'entrée et de sortie de ses briques enfants. De même, lorsque nous avons appelé :meth:`~.bricks.Brick.initialize`, elle a automatiquement transmis la matrice de pondération et la configuration d'initialisation des biais à ses bricks enfants.
|
>>> mlp.initialize() >>> mlp.children[0].parameters[0].get_value() # doctest: +SKIP array([[-0.38312393, -1.7718271 , 0.78074479, -0.74750996], ... [ 1.32390416, -0.56375355, -0.24268186, -2.06008577]]) |
Il existe des cas où l'on souhaite modifier la configuration de la brick parente pour ses enfants. Par exemple, on souhaite initialiser les poids de la première couche d'un MLP de manière légèrement différente des autres. Pour ce faire, il est nécessaire d'examiner de plus près le cycle de vie d'une brick. Dans les deux premières sections, nous avons déjà abordé les trois étapes du cycle de vie d'une brick :
- Construction de brick
- Allocation de ses paramètres
- Initialisation de ses paramètres
Lorsqu'il s'agit d'enfants, le cycle de vie devient un peu plus complexe. (L'intégralité du cycle de vie est documentée dans la classe :class:`~.bricks.Brick`.) Avant d'allouer ou d'initialiser des paramètres, la brique parente appelle ses méthodes :meth:`~.bricks.Brick.push_allocation_config` et :meth:`~.bricks.Brick.push_initialization_config`, configurant les enfants. Si vous souhaitez surcharger la configuration des enfants, vous devrez appeler ces méthodes manuellement, après quoi vous pourrez surcharger la configuration des bricks enfants.
|
>>> mlp = MLP(activations=[Logistic(name='sigmoid_0'), ... Logistic(name='sigmoid_1')], dims=[16, 8, 4], ... weights_init=IsotropicGaussian(), biases_init=Constant(0.01)) >>> y = mlp.apply(x) >>> mlp.push_initialization_config() >>> mlp.children[0].weights_init = Constant(0.01) >>> mlp.initialize() >>> mlp.children[0].parameters[0].get_value() # doctest: +SKIP array([[ 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01], ... [ 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01]]) |