**Description**

Similarly to `@dag` and `@task` decorators, we can add a decorator to easily generate `TaskGroup` instances. 

**Use case / motivation**

This would be used for reusable pieces of DAGs. An example may be to hyperparameter tune an ML pipeline. You may want to generate several parallel subpipelines with different settings/parameters to be executed in parallel. At the end pull the result and decide the best parametrization.  

```py
@task
collect_dataset(...)
  pass

@task
def train_model(...)
   pass

@task_group
def movielens_model_pipeline(learning_rate: int, feature_list: list, dataset: XComArg):  
  dataset = filter_dataset(dataset, feature_list)
  train_model(dataset, learning_rate)

@dag
def movielens_hpt(dataset_path: str, feature_list:list=['year', 'director']):
  dataset = load_dataset(dataset_path)
  for i in range(0.1, 0.9, step=0.1):
    movielens_model_pipeline(i, feature_list, dataset)
  
  decide_best_model(...)
```




**Related Issues**

https://github.com/apache/airflow/issues?q=is%3Aopen+is%3Aissue+label%3AAIP-31


airflow/utils/task_group.py
"""
        If this TaskGroup has immediate upstream TaskGroups or tasks, a dummy node called
        upstream_join_id will be created in Graph View to join the outgoing edges from this
        TaskGroup to reduce the total number of edges needed to be displayed.
        """
        return f"{self.group_id}.upstream_join_id"
    @property
    def downstream_join_id(self) -> str:
        """
        If this TaskGroup has immediate downstream TaskGroups or tasks, a dummy node called
        downstream_join_id will be created in Graph View to join the outgoing edges from this
        TaskGroup to reduce the total number of edges needed to be displayed.
        """
        return f"{self.group_id}.downstream_join_id"
    def get_task_group_dict(self) -> Dict[str, "TaskGroup"]:
        """Returns a flat dictionary of group_id: TaskGroup"""
        task_group_map = {}
        def build_map(task_group):
            if not isinstance(task_group, TaskGroup):
                return
            task_group_map[task_group.group_id] = task_group
            for child in task_group.children.values():
                build_map(child)
        build_map(self)
        return task_group_map
    def get_child_by_label(self, label: str) -> Union["BaseOperator", "TaskGroup"]:
        """Get a child task/TaskGroup by its label (i.e. task_id/group_id without the group_id prefix)"""
        return self.children[self.child_id(label)]
class TaskGroupContext: