1.什么是异构图

简单比喻:

  • 同构图 (Homogeneous) = 纯牛奶(里面只有一种东西:牛奶分子)。

  • 异构图/多部图 (Heterogeneous) = 珍珠奶茶(里面有:牛奶、珍珠、椰果)

场景类比:

场景 A:同构图 (前面1-5做的)

  • 例子:Cora 引用网络。

  • 节点:全是论文。

  • 边:只有一种关系:引用。

  • 特点:因为大家都是论文,所以大家的特征维度都一样(比如都是 1433 维),地位也平等。

    • GNN 处理方式:大家一起去平均,没问题。

场景 B:异构图 (本节内容)

  • 例子:IMDB 电影数据库。

  • 节点:这里有三种东西:演员,电影,导演

  • 边:关系复杂起来:

    • 演员 “出演” 电影 (Actor -> acts_in -> Movie)

    • 导演 “执导” 电影 (Director -> directs -> Movie)

    • 电影 “续集” 电影 (Movie -> sequel -> Movie)

这就是异构图:图里包含不同类型的节点,和不同类型的边。

2.step1:手搓一个异构图

代码:

依然使用kaggle notebook

import torch
from torch_geometric.data import HeteroData

# 1. 创建“杯子”
data = HeteroData()

# ==========================================
# 2. 放入节点 (Nodes) —— 加料
# ==========================================
# 假设我们有: 100部电影, 50个演员, 20个导演

# 电影的特征 (比如: 预算, 时长, 年份) -> 64维
data['movie'].x = torch.randn(100, 64) 

# 演员的特征 (比如: 年龄, 身高, 颜值) -> 32维
# 【重点】注意!演员的特征维度(32)和电影(64)可以不一样!
# 这在同构图里是不可能的,但在异构图里没问题。
data['actor'].x = torch.randn(50, 32)

# 导演的特征 (比如: 获奖数, 执导年限) -> 32维
data['director'].x = torch.randn(20, 32)

# ==========================================
# 3. 放入边 (Edges) —— 混合
# ==========================================
# 边的定义变成了 "三元组" (Source, Relation, Target)

# 关系 A: 演员 -> 出演 -> 电影
# 随机生成一些连接 (这里生成 200 条边)
# row: 演员ID (0-49), col: 电影ID (0-99)
edge_index_actor_movie = torch.randint(0, 50, (2, 200))
edge_index_actor_movie[1] = torch.randint(0, 100, (200,))
data['actor', 'acts_in', 'movie'].edge_index = edge_index_actor_movie

# 关系 B: 导演 -> 执导 -> 电影
# 生成 100 条边
edge_index_director_movie = torch.randint(0, 20, (2, 100))
edge_index_director_movie[1] = torch.randint(0, 100, (100,))
data['director', 'directs', 'movie'].edge_index = edge_index_director_movie

print("🧋 珍珠奶茶 (异构图) 制作完成!")
print(data)

输出:


3.step2:搭建模型

代码:

#模型搭建
from torch_geometric.nn import SAGEConv, HeteroConv, Linear

class HeteroGNN(torch.nn.Module):
    def __init__(self, hidden_channels, out_channels):
        super().__init__()
        
        # HeteroConv 是一个 "容器"
        # 里面装了一个字典,定义了每种关系用什么 GNN 去处理
        self.conv1 = HeteroConv({
            # 这里的 SAGEConv((-1, -1), ...) 意思是不强制输入维度,自动推断
            # 逻辑:演员(32) -> 电影(64)。SAGE 会自动把 32 投影到 64
            ('actor', 'acts_in', 'movie'): SAGEConv((-1, -1), hidden_channels),
            
            # 逻辑:导演(32) -> 电影(64)
            ('director', 'directs', 'movie'): SAGEConv((-1, -1), hidden_channels),
            
            # 注意:如果还需要电影自己更新自己 (比如电影互相有边),也要在这里加
        }, aggr='sum') # aggr='sum': 两位大厨的结果加起来

        self.conv2 = HeteroConv({
            ('actor', 'acts_in', 'movie'): SAGEConv((-1, -1), out_channels),
            ('director', 'directs', 'movie'): SAGEConv((-1, -1), out_channels),
        }, aggr='sum')

    def forward(self, x_dict, edge_index_dict):
        # === Layer 1 ===
        x_dict_out1 = self.conv1(x_dict, edge_index_dict)
        x_dict_out1 = {key: x.relu() for key, x in x_dict_out1.items()}

        # === 关键修复:保留食材 ===
        # 第一层输出的 x_dict_out1 只有 {'movie': ...}
        # 我们需要把它和原始的 x_dict 合并,确保 'actor' 和 'director' 还在
        # 这样第二层卷积才能找到输入源
        x_dict_layer2 = x_dict.copy()   # 复制一份原始菜单(包含 actor, director, movie)
        x_dict_layer2.update(x_dict_out1) # 用第一层煮好的新 movie 覆盖旧 movie

        # 现在 x_dict_layer2 里面有:
        # 1. 原始的 actor (Layer 2 需要作为源)
        # 2. 原始的 director (Layer 2 需要作为源)
        # 3. 升级后的 movie (Layer 2 需要作为目标)

        # === Layer 2 ===
        x_dict_out2 = self.conv2(x_dict_layer2, edge_index_dict)

        return x_dict_out2

# 初始化模型
# 假设我们要把电影分类成 3 种类型 (动作, 爱情, 科幻)
model = HeteroGNN(hidden_channels=64, out_channels=3)

print("\n🚀 模型搭建完毕!结构如下:")
print(model)

是不是说 演员维度 + 导演维度 一定要等于 电影维度?

完全不需要。

GNN 内部有一个权重矩阵W,形状是 [32, 64],它把 32 维的演员特征,强制乘变成 64 维。

演员可以是 10 维,导演可以是 1000 维,电影可以是 5 维,随便设置。

唯一的要求是两位大厨的输出维一定要相同,因为最后要把这两盘菜倒进同一个碗里。

为什么要有两个大厨

  • 比喻:

    • 大厨 A (处理演员):他是个“切肉师傅”。他知道处理演员数据需要“切片”。

    • 大厨 B (处理导演):他是个“煲汤师傅”。他知道处理导演数据需要“慢炖”。

如果只有一个大厨,他要是用切肉的手法去处理汤,或者用煲汤的手法去处理肉,结果就乱套了。

  • 数学层面:

    • 演员特征里的“第 1 位”代表“颜值”。

    • 导演特征里的“第 1 位”代表“获奖数”。

    • 如果共用一个矩阵 W,模型就会把“颜值”和“获奖数”混为一谈,用同样的系数去乘它们。

异构图的精髓就在于:针对每种关系,我们都训练一套独立的参数(专属大厨)来专门提取这种关系的特征。

顺序:

在代码里,它们其实是并行发生的(或者逻辑上独立的)。

  1. 大厨 A 算出:演员给电影贡献了多少分。

  2. 大厨 B 算出:导演给电影贡献了多少分。

  3. 最后一步 (Aggregate):电影的总得分 = 演员分 + 导演分。

为什么还用到了 SAGEConv

SAGEConv 就是在上一章GraphSAGE里学过的,在这里它被雇佣了。

这里有一个层级关系:

  • HeteroConv (包工头/容器):

    • 它的作用只是管理。它手里拿着一个清单(字典),上面写着:“对于演员-电影这条边,请 A 工人去干;对于导演-电影这条边,请 B 工人去干。”

    • 它自己不会算卷积,它只负责分配任务和最后汇总。

  • SAGEConv (工人/具体的算法):

    • 这是真正干活的。你之前学过 GraphSAGE,它擅长**“聚合邻居特征并取平均”**。

    • 在这里,我们觉得 GraphSAGE 的算法(Sampling + Mean Aggregation)很好用,所以我们决定:每一条边内部的特征提取,都沿用 GraphSAGE 的算法。

能不能换? 可以:

  • 如果“注意力机制”更好,可以把里面的 SAGEConv 换成 GATConv

  • 如果最简单的“加权求和”就够了,可以换成 GCNConv

4.step3:运行模型

代码

# 运行模型
with torch.no_grad():
    output_dict = model(data.x_dict, data.edge_index_dict)

print("\n✨ 输出结果:")
# 我们只关心 "movie" 的结果,因为所有信息都聚合并更新到了 movie 上
movie_output = output_dict['movie']
print(f"电影节点的输出维度: {movie_output.shape}") 
# 预期: [100, 3] -> 100部电影,每部3个分类概率

5.补充内容

1.HAN-引入attention

这是 GAT 在异构图上的进化版。它引入了 两层注意力:

  1. 节点级注意力:

    在“演员→电影”这条边里,模型会自动学习:莱昂纳多权重 0.9,路人甲权重 0.01。
  2. 语义级注意力:

    • 模型会思考:对于判断电影类型,是“演员”更重要,还是“导演”更重要?

    • 也许判断“动作片”看演员,判断“文艺片”看导演。

代码:

把step2代码里的 SAGEConv 换成 GATConv。

#替换step2的cell
import torch
import torch.nn.functional as F
from torch_geometric.nn import HeteroConv, GATConv

class HeteroGNN(torch.nn.Module):
    def __init__(self, hidden_channels, out_channels):
        super().__init__()
        
        # === 第一层:多头 GAT ===
        self.conv1 = HeteroConv({
            # GATConv 参数解析:
            # 1. (-1, -1): 自动推断输入维度 (演员/导演 -> 电影)
            # 2. hidden_channels: 每个头的输出维度
            # 3. heads=2: 使用 2 个注意力头 (你可以改成 4 或 8)
            # 4. add_self_loops=False: 【关键】异构二部图必须关掉自环
            ('actor', 'acts_in', 'movie'): GATConv((-1, -1), hidden_channels, 
                                                   heads=2, add_self_loops=False),
            
            ('director', 'directs', 'movie'): GATConv((-1, -1), hidden_channels, 
                                                      heads=2, add_self_loops=False), 
        }, aggr='sum')

        # === 第二层:输出层 ===
        self.conv2 = HeteroConv({
            # 注意:这里的输入维度不需要手动算,(-1, -1) 会自动处理上一层拼接后的维度
            # 重点:concat=False
            # 我们希望最终输出就是 out_channels (3类),而不是 out_channels * heads
            ('actor', 'acts_in', 'movie'): GATConv((-1, -1), out_channels, 
                                                   heads=1, concat=False, add_self_loops=False),
            
            ('director', 'directs', 'movie'): GATConv((-1, -1), out_channels, 
                                                      heads=1, concat=False, add_self_loops=False),
        }, aggr='sum')

    def forward(self, x_dict, edge_index_dict):
        # === Layer 1 ===
        x_dict_out1 = self.conv1(x_dict, edge_index_dict)
        
        # GAT 论文推荐使用 ELU 激活函数,而不是 ReLU
        x_dict_out1 = {key: F.elu(x) for key, x in x_dict_out1.items()}

        # === "补货"逻辑 (解决之前演员导演丢失的报错) ===
        x_dict_layer2 = x_dict.copy()
        x_dict_layer2.update(x_dict_out1)

        # === Layer 2 ===
        x_dict_out2 = self.conv2(x_dict_layer2, edge_index_dict)
        
        return x_dict_out2

#初始化模型
# hidden_channels=64: 中间层维度
# out_channels=3: 假设我们要把电影分 3 类
model = HeteroGNN(hidden_channels=64, out_channels=3)

2.元路径-寻找隐形关系

有时候,直接关系看不出什么,间接关系才是核心。

例子:演员 A - 电影 - 演员 B。

这条路径叫“共演”。如果 A 和 B 经常一起演戏,他们之间就有一条隐形的强连接。

我们可以在图里人为地添加这条“虚拟边”,让模型学习“圈子文化”。

干嘛来的呢?比如可以预测演员得奖概率。

没有元路径时,模型只能看到:“莱昂纳多演了《泰坦尼克号》”。它不知道莱昂纳多和谁搭档过,除非它先把信息传给电影,电影再传回给另一个演员(路径太长,信号会衰减)。

有了元路径时,模型直接看到了:“莱昂纳多 和 另一个演员A是共演关系,如果一个演员经常和“奥斯卡影帝”一起演戏(Co-star 边很多),那么大概率他自己的演技也不差,或者他在一个很高端的圈子里。

所以,元路径的作用就是缩短信息传递的距离,把隐形的社交关系显式地画出来。

代码实现:

不同于HAN只用改step2的类和forward,元路径麻烦一些。

核心:把“元路径”当成一条“新造出来的边”。

比如:演员 -> 电影 -> 演员(共演关系)。

  1. 数据层面:你手动算出 A 和 B 是共演,直接在 data 里加一条新边 ('actor', 'co_star', 'actor')

  2. 模型层面:给这条新边专门配一个 GATConv 大厨。

#数据准备
# 必须先安装 (如果你还没装)
!pip install -q torch_geometric

import torch
from torch_geometric.data import HeteroData

# ==========================================
# 1. 创建“容器”
# ==========================================
data = HeteroData()

# ==========================================
# 2. 放入节点 (Nodes) —— 加料
# ==========================================
num_movies = 100
num_actors = 50
num_directors = 20

# 电影特征 (100, 64)
data['movie'].x = torch.randn(num_movies, 64) 
# 演员特征 (50, 32)
data['actor'].x = torch.randn(num_actors, 32)
# 导演特征 (20, 32)
data['director'].x = torch.randn(num_directors, 32)

# ==========================================
# 3. 放入边 (Edges) —— 混合
# ==========================================

# --- 关系 A: 演员 -> 出演 -> 电影 ---
# 生成 200 条随机边
edge_index_actor_movie = torch.randint(0, num_actors, (2, 200)) # 随机生成
edge_index_actor_movie[1] = torch.randint(0, num_movies, (200,)) # 修正第二行是电影ID
data['actor', 'acts_in', 'movie'].edge_index = edge_index_actor_movie

# --- 关系 B: 导演 -> 执导 -> 电影 ---
edge_index_director_movie = torch.randint(0, num_directors, (2, 100))
edge_index_director_movie[1] = torch.randint(0, num_movies, (100,))
data['director', 'directs', 'movie'].edge_index = edge_index_director_movie


# ==========================================
# 4. 【这里开始改代码】加入元路径 (Metapath): Actor-Movie-Actor
# ==========================================
print("正在计算共演关系 (Co-star)...")

# 步骤 1: 把 edge_index 变成稀疏矩阵 (Sparse Matrix)
# 这是一个 (Actor x Movie) 的矩阵,也就是 [50, 100]
# 值为 1 代表演过,0 代表没演过
adj = torch.sparse_coo_tensor(
    edge_index_actor_movie, 
    torch.ones(edge_index_actor_movie.shape[1]), # 边的权重全设为 1
    (num_actors, num_movies) # 矩阵大小
)

# 步骤 2: 矩阵乘法 (Magic Happens Here!)
# (Actor x Movie) 乘以 (Movie x Actor) = (Actor x Actor)
# 结果矩阵中,如果 (i, j) 位置的值 > 0,说明演员 i 和演员 j 合作过
# t() 是转置的意思
co_star_matrix = torch.sparse.mm(adj, adj.t())

# 步骤 3: 把矩阵变回 edge_index
# coalesced() 是为了整理稀疏矩阵的内部结构,indices() 提取坐标
co_star_edge_index = co_star_matrix.coalesce().indices()

# 步骤 4: 去掉自环 (Self-loops)
# 矩阵乘法会算出 "莱昂纳多和莱昂纳多合作过",这没意义,我们要去掉
row, col = co_star_edge_index
mask = row != col  # 只保留 row 不等于 col 的边
data['actor', 'co_star', 'actor'].edge_index = co_star_edge_index[:, mask]

print("✅ 异构图制作完成!")
print("-" * 30)
print(data)
print("-" * 30)
# 验证一下
print(f"生成的共演边数量: {data['actor', 'co_star', 'actor'].edge_index.shape[1]}")
#类
#还用 GATConv。新加一条边就行。
class HeteroGNN(torch.nn.Module):
    def __init__(self, hidden_channels, out_channels):
        super().__init__()
        
        # === Layer 1 ===
        self.conv1 = HeteroConv({
            # 1. 原有的:演员 -> 电影
            ('actor', 'acts_in', 'movie'): GATConv((-1, -1), hidden_channels, heads=2, add_self_loops=False),
            
            # 2. 原有的:导演 -> 电影
            ('director', 'directs', 'movie'): GATConv((-1, -1), hidden_channels, heads=2, add_self_loops=False),
            
            # 3. 【新增元路径】:演员 <-> 演员 (Co-star)
            # 逻辑:让演员通过共演关系,聚合其他演员的特征,更新自己!
            # 注意:源是 actor,目标也是 actor,所以 add_self_loops=True 是安全的,
            # 但为了严谨通常 Metapath 也可以设为 False,看具体需求。
            ('actor', 'co_star', 'actor'): GATConv((-1, -1), hidden_channels, heads=2, add_self_loops=False),
            
        }, aggr='sum') 

        # === Layer 2 ===
        self.conv2 = HeteroConv({
            # 原有的边...
            ('actor', 'acts_in', 'movie'): GATConv((-1, -1), out_channels, heads=1, concat=False, add_self_loops=False),
            ('director', 'directs', 'movie'): GATConv((-1, -1), out_channels, heads=1, concat=False, add_self_loops=False),
            
            # 【新增元路径】
            # 如果你想让演员分类,这里可以加。
            # 但如果你只是想给电影分类,第二层甚至可以不加这条边!
            # 这里假设我们只用元路径来增强第一层的特征提取。
        }, aggr='sum')

    
#forward调整更新目标。
#以前只有 Movie 被更新。
#现在因为有一条边是 actor -> actor,所以 Actor 的特征也会被更新了
#forward函数记得贴在class名下,别忘了缩进

def forward(self, x_dict, edge_index_dict):
        # === Layer 1 ===
        # 这里 HeteroConv 会自动处理那条 'co_star' 的边
        # 结果:x_dict_out1 里面不仅有 'movie',现在也有 'actor' 了!(因为 actor 也是目标节点了)
        x_dict_out1 = self.conv1(x_dict, edge_index_dict)
        
        x_dict_out1 = {key: F.elu(x) for key, x in x_dict_out1.items()}

        # === 补货逻辑 (Update) ===
        # 这一步非常重要!
        # 以前:x_dict_out1 只有 movie,我们把原始 actor 补回去。
        # 现在:x_dict_out1 已经有了“升级版”的 actor (通过元路径学到了东西)。
        # 所以 update 的时候,新 actor 会覆盖旧 actor。这是我们想要的!
        x_dict_layer2 = x_dict.copy()
        x_dict_layer2.update(x_dict_out1) 
        
        # 现在 x_dict_layer2 里:
        # - movie: 1级进化 (吸收了演员+导演)
        # - actor: 1级进化 (吸收了共演伙伴的信息) <--- 元路径的功劳
        # - director: 原始版 (因为没给他加元路径)

        # === Layer 2 ===
        x_dict_out2 = self.conv2(x_dict_layer2, edge_index_dict)
        
        return x_dict_out2

#记得粘贴上初始化模型

3.反向边的“闭环逻辑”

信息双向流动,食材(演员和导演)的信息也更新了。

例子:

  • 初始状态 (Layer 0):

    • 莱昂纳多 (Actor):特征可能只是 [48岁, 男, 183cm]。这只是物理属性。

    • 泰坦尼克号 (Movie):特征可能只是 [1997年, 2亿预算]。这只是商业属性。

    • 此时,莱昂纳多只是个普通的中年男子,泰坦尼克号只是一部烧钱的旧片子。

  • 没有反向边 (单向流动:Actor → Movie):

    • Movie 变强了:泰坦尼克号吸收了莱昂纳多的信息,它知道:“哦,我是一部由 183cm 中年男子主演的电影”。

    • Actor 还是傻的:莱昂纳多的特征依然是 [48岁, 男, 183cm]。他根本不知道自己演过《泰坦尼克号》,也不知道自己是“影帝”。他的身份没有因为他的作品而得到升华。

  • 有反向边 (闭环逻辑:Actor $\leftrightarrow$ Movie):

    • Layer 1 (Ping-Pong 回合 1):电影吸收演员信息。

    • Layer 2 (Ping-Pong 回合 2):关键来了! 莱昂纳多吸收了电影的信息。

      • 他的特征变成了:[48岁, 男, 183cm] + [主演过2亿预算的大片]

      • 提升:他的“行业地位”和“上下文信息”。他不再是一个物理人,而是一个“演过大片的演员”。

    • Layer 3 (Ping-Pong 回合 3):电影再次吸收这个“升级版”莱昂纳多的信息。

      • 电影:“哇,主演我的不是普通人,而是‘演过大片的资深演员’!”

      • 电影的特征再次提升,分类更加准确。

总结:

“闭环”提升的是节点对自己身份的认知。

如果不双向流动,源节点(演员)永远是静态的背景板;双向流动后,源节点变成了动态的参与者,它的特征会随着网络结构不断进化。

代码:

#数据准备
#原来的 edge_index 翻转一下(Row 变 Col,Col 变 Row)

import torch
from torch_geometric.data import HeteroData

# 此处省略1. 创建“容器”,2. 放入节点, 3. 放入边的代码
#在放入边的最后加入反向边

print("正在添加反向边 (Reverse Edges)...")

# === 1. 演员 -> 电影 的反向:电影 -> 演员 ===
# 原来的边: data['actor', 'acts_in', 'movie'].edge_index  (形状 [2, 200])
# 翻转逻辑: edge_index[[1, 0]] 也就是把第一行和第二行对调
data['movie', 'rev_acts_in', 'actor'].edge_index = data['actor', 'acts_in', 'movie'].edge_index[[1, 0]]

# === 2. 导演 -> 电影 的反向:电影 -> 导演 ===
data['movie', 'rev_directs', 'director'].edge_index = data['director', 'directs', 'movie'].edge_index[[1, 0]]

print("✅ 反向边添加完成!现在的图结构:")
print(data)
# 你会发现多了两行 edge_type
#模型构建
#现在图里有了 4 种关系(2正 + 2反),我们需要在 HeteroGNN 里处理它们
#有了反向边,所有节点(演员、导演、电影)都会作为“目标节点”被更新
#不再需要那个手动的 x_dict.update(...) 补货逻辑了

from torch_geometric.nn import HeteroConv, GATConv, SAGEConv
import torch.nn.functional as F

class HeteroGNN_BiDirectional(torch.nn.Module):
    def __init__(self, hidden_channels, out_channels):
        super().__init__()
        
        # === Layer 1: 所有边都要参与 ===
        self.conv1 = HeteroConv({
            # 正向:演员/导演 -> 电影
            ('actor', 'acts_in', 'movie'): GATConv((-1, -1), hidden_channels, add_self_loops=False),
            ('director', 'directs', 'movie'): GATConv((-1, -1), hidden_channels, add_self_loops=False),
            
            # 【新增】反向:电影 -> 演员/导演
            # 逻辑:演员也要听听电影怎么说,提升自己的特征
            ('movie', 'rev_acts_in', 'actor'): GATConv((-1, -1), hidden_channels, add_self_loops=False),
            ('movie', 'rev_directs', 'director'): GATConv((-1, -1), hidden_channels, add_self_loops=False),
        }, aggr='sum')

        # === Layer 2: 继续所有边参与 (闭环形成) ===
        self.conv2 = HeteroConv({
            # 正向
            ('actor', 'acts_in', 'movie'): GATConv((-1, -1), out_channels, add_self_loops=False),
            ('director', 'directs', 'movie'): GATConv((-1, -1), out_channels, add_self_loops=False),
            
            # 反向 (虽然我们最后只分类电影,但保持反向边可以让网络更加稳健,
            # 或者如果你想顺便给演员分类,这里就是必须的)
            ('movie', 'rev_acts_in', 'actor'): GATConv((-1, -1), out_channels, add_self_loops=False),
            ('movie', 'rev_directs', 'director'): GATConv((-1, -1), out_channels, add_self_loops=False),
        }, aggr='sum')

    def forward(self, x_dict, edge_index_dict):
        # === Layer 1 ===
        # PyG 很聪明:因为你定义了指向 actor 的卷积,
        # 所以 x_dict_out1 里面会自动包含更新后的 'actor' 和 'director'!
        # 我们再也不用手动 copy/update 了。
        x_dict_out1 = self.conv1(x_dict, edge_index_dict)
        
        x_dict_out1 = {key: F.elu(x) for key, x in x_dict_out1.items()}

        # === Layer 2 ===
        # 直接把全是新特征的字典扔进去
        x_dict_out2 = self.conv2(x_dict_out1, edge_index_dict)
        
        return x_dict_out2

# 初始化并测试
model = HeteroGNN_BiDirectional(hidden_channels=64, out_channels=3)

# 跑一次看看
with torch.no_grad():
    out = model(data.x_dict, data.edge_index_dict)

print("\n✨ 闭环逻辑测试结果:")
print(f"电影特征维度: {out['movie'].shape}")
print(f"演员特征维度: {out['actor'].shape} (演员也被更新了)")
print(f"导演特征维度: {out['director'].shape}")

运行模型部分同原来一样

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐