Information additionnelle dans les fonctions virtuelles

a marqué ce sujet comme résolu.

Bonjour à tous.

Une fois de plus, c'est une question de design qui m'amène. J'utilise principalement le polymorphisme dans le but de proposer plusieurs algorithmes fondamentalement différents pour résoudre un même problème. Prenons ici un exemple réel : la construction d'un modèle sur base d'une collection de points.

Le principe est simple. Je dois pouvoir entrainer mon modèle sur une base de données. Une fois que c'est fait, je dois pouvoir l'évaluer sur de nouveaux points. On a donc une classe abstraite qui ressemble à ça.

1
2
3
4
5
6
7
8
class Model
{
  public:
    virtual          ~Model() {}
    virtual Model*   clone() const = 0;
    virtual void     train(const TrainingSet& training_set) = 0;
    virtual void     evaluate(TrainingSet::Element& element) = 0;
};

Certains des algorithmes qui seront implémentés ont néanmoins besoin d'informations supplémentaires, comme des valeurs d'hyperparamètres. Je vois deux façons de faire, j'aimerais votre avis sur la question.

Première idée : chaque classe est responsable de collecter ses info avant l'appel à la fonction train. Exemple avec un algorithme quelconque nécessitant un critère de convergence via un epsilon.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
class ModelToto : public Model
{
  public:
                     ModelToto() : Model(), epsilon(1.0e-12) {}
                     ModelToto(double eps) : Model(), epsilon(eps) {}
                     ModelToto(const ModelToto& model) : Model(model), epsilon(model.epsilon) {}
    virtual          ~ModelToto() {}
    virtual Model*   clone() const { return new ModelToto(*this); }

    void             setEpsilon(double eps) { this->epsilon = eps; }
    virtual void     train(const TrainingSet& training_set) { /* algorithme utilisant this->epsilon */ }
    virtual void     evaluate(TrainingSet::Element& elem) { /* evaluation n'utilisant pas this->epsilon */ }

  protected:
    double           epsilon;
}

Cette façon de faire me semble bien propre, si ce n'est deux choses :

  • Je dois stocker des informations dans la classe qui ne servent que durant l'exécution de la fonction train. Ici c'est juste un double, ce n'est pas énorme. Mais en pratique ce sont souvent des vecteurs d'hyperparamètes pré-fixés.
  • Lorsque je manipule un Model*, je n'ai plus aucun contrôle sur les hyperparamètres. En particulier, certains des algorithmes ont une première phase d'optimisation de ces hyperparamètres. Une fois ceux-ci optimisés, l'entrainement est fait avec les meilleurs valeurs. Il m'est alors impossible de faire une copie de mon modèle via clone() et de lui dire "fais moi un train() avec les mêmes hyperparamètres sur cette nouvelle base de données" sans avoir moi-même connaissance de la classe finale.

J'ai donc envisagé une solution différente, mais qui contredit peut-être les principes du polymorphisme. J'ajoute à ma fonction train un pointeur vide par défaut que l'utilisateur peut utiliser pour donner de l'information supplémentaire à son algorithme.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class TrainingInfo
{
  public:
    virtual                 ~TrainingInfo() {}
    virtual TrainingInfo*   clone() const = 0;
};

class Model
{
  public:
    virtual               ~Model() {}
    virtual Model*        clone() const = 0;
    void                  train(const TrainingSet& training_set, const TrainingInfo* info = 0)
                          {
                            this->trainingInfo = info;
                            this->trainModel();
                          }
    const TrainingInfo*   getInfo() const { return this->trainingInfo; }
    virtual void          evaluate(TrainingSet::Element& element) = 0;
  protected:
    virtual void          trainModel() = 0;
  protected:
    const TrainingInfo*   trainingInfo;
};

L'utilisateur est alors libre de définir une classe héritant de TrainingInfo et de l'utiliser dans son algorithme.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class TrainingInfoToto : public TrainingInfo
{
  public:
                            TrainingInfoToto(double eps) : TrainingInfo(), epsilon(eps) {}
                            TrainingInfoToto(const TrainingInfoToto& info) : TrainingInfo(info), epsilon(info.epsilon) {}
    virtual                 ~TrainingInfoToto() {}
    virtual TrainingInfo*   clone() const { return new TrainingInfoToto(*this); }
    double                  getEpsilon() const { return this->epsilon; }

  protected:
    double                  epsilon;
};

class ModelToto : public Model
{
  public:
                     ModelToto() : Model() {}
                     ModelToto(const ModelToto& model) : Model(model) {}
    virtual          ~ModelToto() {}
    virtual Model*   clone() const { return new ModelToto(*this); }

    virtual void     evaluate(TrainingSet::Element& elem) { /* ... */ }

  protected:
    virtual void     trainModel()
                     {
                       TrainingInfoToto* toto_info = 0;
                       if(this->trainingInfo)
                         toto_info = dynamic_cast<const TrainingInfoToto*>(this->trainingInfo);
                       /* algorithme utilisant toto_info->getEpsilon() s'il existe */
                     }
}

Cette configuration me permet de faire par exemple des choses comme ceci qui ne nécessite pas de connaître le type final de la classe (je ne vois pas comment faire autrement) :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
double evaluate_quality(const Model* model, const TrainingSet& training_set)
{
  TrainingInfo* tmp_info = model->getInfo().clone();
  tmp_info->lockHyperparameters();
  Model* tmp_model = model->clone();
  temp_model->train(training_set, info);
  //...
  delete tmp_model;
  delete tmp_info;
}

Qu'en pensez-vous ? Y a-t-il d'autres façons de faire ce genre de chose ?

De façon générale, est-ce une bonne idée d'ajouter un pointeur nul par défaut aux fonctions virtuelles qui pourraient potentiellement avoir besoin de plus d'arguments ?

Merci d'avance.

+0 -0

Bonjour,

Quelque chose ne va pas ici :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
double evaluate_quality(const Model* model, const TrainingSet& training_set)
{
  TrainingInfo* tmp_info = model->getInfo().clone();
  tmp_info->lockHyperparameters();
  //...
}

class TrainingInfo
{
  public:
    virtual                 ~TrainingInfo() {}
    virtual TrainingInfo*   clone() const = 0;
};

Où est la méthode lockHyperparameters dans la classe TrainingInfo ? Pour l'utiliser tu devrais caster ce pointeur vers le type réel, ce qui revient, si j'ai bien compris, à connaitre le type réel de ton modèle : incompatible avec le polymorphisme. Formuler autrement, j'ai un modèle (je ne connais pas son type réel), comment je sais ce que je peux lui passer comme information supplémentaire ?

Dans ta première solution tu n'as pas ce problème puisque tu donnes les informations supplémentaire à un instant où tu connais encore le type réel de ton modèle (à la création de celui-ci).

Oui, j'ai simplifié les exemples. En réalité, j'ai effectivement un cast vers un TrainingInfoHyperParam qui peut retenir s'il faut ou non fixer les hyperparamètres. J'aurais du le préciser.

Tu ne sais pas ce que tu peux lui passer, mais tu peux lui passer ce qui sort de model->getInfo().

+0 -0

Comment tu sais vers quoi caster ton TrainingInfo* dans evaluate_quality ?

Ça revient au même, je fait mon model->getInfo(), je fais quoi avec le retour ? Il faut nécessairement que je le cast pour l'utiliser, ce qui revient à connaitre le type réel du modèle.

Ca reste un exemple trop simplifié. En réalité j'ai une hiérarchie de classes assez complexes et ma fonction evaluate_quality est en réalité une fonction de la class ModelHP, qui sait donc que le type du modèle est au moins ModelHP.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class Model;  // ABSTRAITE
class Model::TrainingInfo;  // ABSTRAITE

class ModelHP : public Model; // ABSTRAITE - algo utilisant des hyper paramètres
class ModelHP::TrainingInfo : public Model::TrainingInfo;  // ABSTRAITE - Peut mettre un lock sur les paramètres

class ModelAlgoType1 : public ModelHP;
class ModelAlgoType1::TrainingInfo : public ModelHP::TrainingInfo;  // Contient la description des hyperparamètres spécifique à ce type d'algo

class ModelAlgoType1Impl1 : public ModelAlgoType1;
class ModelAlgoType2Impl2 : public ModelAlgoType1;

Et dans ma classe ModelHP j'ai un algorithme général pour ce type de problème qui fait ceci (précisons également que j'ai une fonction Model::cloneMethod() qui copie l'objet sans les constructions temporaires due à l'entrainement) :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
double ModelHP::toto(const TrainingSet& training_set)
{
  Model* model = this->cloneMethod();
  ModelHP::TrainingInfo* info = dynamic_cast<ModelHP::TrainingInfo*>(this->trainingInfo->clone());
  info->lockHP();
  model->train(training_set, info);
  /* ... */
  delete info;
  delete model;
  return result;
}

Ca explique ma motivation à ajouter ce genre d'argument par défaut à une fonction virtuelle. Mais ma vraie question est plus générale sur cette pratique.

+0 -0

La présence des classes intermédiaire change tout : elle ajoute de l'information, celle qui manquait pour pouvoir manipuler les informations additionnelles. Il y a une nécessité à la redondance des informations additionnelles ? Pourquoi les mettre à la fois dans la classe et en paramètre ?

Pourquoi ne pas directement avoir directement (en suivant toujours ton idée) ?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#include<memory>

struct Model
{
    virtual void train() = 0;
    virtual std::unique_ptr<Model> clone() = 0;

    template<class M>
    typename M::Info* get_info()
    { return static_cast<typename M::Info*>(i); }

    virtual ~Model(){}

protected:
    struct Info
    {
        virtual ~Info(){}
    };


    Model(const Model&)
    {}

private:
    Info* i;
};

struct ModelA : Model
{
    struct Info : Model::Info
    {
        virtual void lock() =0;
    };

    void eval()
    {
        auto m = clone();
        m->get_info<ModelA>()->lock();
        m->train();
    }
};

struct ModelAA : ModelA
{
    struct Info : ModelA::Info
    { };

    void train(){}
    std::unique_ptr<Model> clone()
    { return std::make_unique<ModelAA>(*this); }
};

PS: Code partiel, je n'ai pas fait attention à tout.

A part la méthode clone, le reste est en C++98 :)

A l'heure actuelle, tu clones le modèle et les informations additionnelles, tu modifies ces dernières et tu les redonnes au nouveau modèle. Pourquoi ne pas laisser le modèle cloner les informations additionnelles et toi tu lui demandes juste de te les redonner ensuite (la méthode get_info) de mon code. Du coup tu n'as même plus besoin de repasser les informations additionnelles dans train, le modèle les a déjà.

Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte