
神经网络变得轻松(第四十部分):在大数据上运用 Go-Explore
概述
在上一篇文章 “神经网络变得轻松(第三十九部分):Go-Explore,一种不同的探索方式” 中,我们领略了 Go-Explore 算法,及其探索环境的能力。 您也许还记得,该算法包括 2 个阶段:
- 第 1 阶段 — 探索
- 第 2 阶段 — 依据样本训练策略
在第 1 阶段,我们使用随机动作选择来获得尽可能完整的环境全景。 这种方式令我们能够收集足够的样本数据库,令其能在一个日历月内基于历史数据成功训练代理者。 我们构建的模型能够依据训练集找到一种可盈利的策略。
但一个日历月的时间周期,对于总结数据,并制定在可预见的未来都能盈利的策略,无疑太短暂。 由此,为了寻找我们的策略,我们被迫增加训练周期。 当我们将训练周期延长到三个月时,我们发现使用随机动作选择无法产生一次可盈利的验算。
根据概率论,这是一个完全可预期的结果。 毕竟,一个总体事件的概率等于其所有组成部分的概率乘积。 但由于每个单独事件的概率小于 1,因此随着步数的增加,获得可盈利验算的概率就会降低。
此外,随着训练周期的增加,环境可能会发生变化,从而影响代理者的学习结果。 故此,定期监控代理者的性能,并在中间阶段分析其性能非常重要。
为了改进覆盖长周期训练结果,可以应用 Go-Explore 算法的各种优化方法,例如,使用改进的动作选择方法。 这种方式应考虑到任务的更广泛背景,并允许代理者做出更明智的决策。
在本文中,我们将仔细研究 Go-Explore 算法的可能优化方法,以提高其在覆盖长周期训练的绩效。
1. 随着训练周期的增加,运用 Go-Explore 的困难
Go-Explore 算法随着训练周期的增加,浮现出一定的困难。 其中一些包括:
-
维度诅咒:随着训练周期的增加,代理者可以访问的状态数量呈指数级增长,这令查找最优策略变得更加困难。
-
环境变化:随着训练周期的增加,环境也许会发生变化,而这也许会影响代理者的学习成果。 这会导致以前成功的策略变得无效,甚至不可能。
-
选择动作困难:随着训练周期的增加,代理者也许需要考虑任务的更广泛背景,以便做出明智的决定。 这会令选择最佳行的任务复杂化,并且需要更复杂的方法来优化算法。
-
训练时间增加:随着训练周期的增加,收集足够数据和训练模型所需时间也会增加。 这会降低代理者训练的效率和速度。
随着训练周期的增加,需要探索的状态空间维度也许会出现增加的问题。 这也许会导致 “维度诅咒” 问题,其中可能状态的数量随着维度的增加呈指数增长。 这令状态空间探索变得困难,并可能导致算法花费太多时间探索不相关的状态。
为了解决这个问题,可以使用降维技术,例如 PCA。 它们能够降低状态空间的维数,同时维护有关数据结构的信息。 我们还可以利用重要特征选择技术来降低状态空间的维数,并专注于问题最关切的方面。
此外,我们还可以运用其它方法,例如基于进化或遗传算法的优化,这令我们能够在大状态空间中寻找最优解。 这些方法令我们能够探索代理者行为的各种选项,并为给定任务选择最优解。
还可以采用各种行动选择方式,例如基于置信度的探索方法,它允许代理者探索状态空间的新区域,不仅考虑到获得奖励的概率,还考虑到有关任务知识的信心。 这有助于避免陷入局部最优的问题,并允许更有效地探索状态空间。
强化学习(RL)典型范例是计算机游戏或其它人工模拟的环境,其是固定的,这意味着它们不会随时间而变化。 不过,在现实世界的应用中,环境也许会随时间而变化,从而影响代理者的学习成果。
当运用 Go-Explore 算法,其中包括获取历史数据的环境探索步骤之时,若随后依据历史数据进行代理者训练时,环境的变化可能会导致意外结果。
例如,如果代理者已经接受了若干个月的数据训练,而在这期间环境发生了变化,诸如游戏规则的变化,或新物体的出现,那么代理者也许无法应对新环境,其以前成功的策略可能会变得无效,甚至不可能。
为了降低环境变化对代理者训练结果的影响,有必要在贯穿整个代理者训练过程中定期监测环境,并分析其变化。 如果检测到环境中的重大变化,则必须依据更新后的数据和算法重启代理者训练过程。
我们还可以采用在训练过程中考虑环境变化的训练方法,诸如基于模型的强化学习(RL)方法,该方法构建环境模型,并用它来预测未来的状态和奖励。 这令代理者能够适应环境的变化,并做出更明智的决策。
也可以采用其它优化技术,诸如更改算法的超参数,或更改算法本身,使之训练更有效。
通常,运用 Go-Explore 算法基于较长周期来训练代理者可能非常复杂,并且需要许多技术方案和改进。
结果就是,运用 Go-Explore 算法可能非常复杂,需要许多技术方案和改进。 Go-Explore 算法是一个强力的工具,适合探索复杂环境,以及含有大量状态和动作的训练代理者任务。 但其有效性可能会随着训练周期的增加,和任务条件的变化而降低。 故此,需要采用各种优化方法和参数调优,来达成最佳效果。这可能是一个非常实用,和有前途的研究方向。
2. 优化方式的选项
考虑到上面所说的,延长训练周期需要一种更谨慎的方式,而不是简单地在策略测试器中指定新日期,并加载额外的历史数据。 为了创建真实的交易策略,必须依据尽可能多的历史数据训练模型。 只有这种方式才能让我们创建一个有能力在未来产生盈利的模型。
在本文中,我们不会让模型复杂化。 取而代之的是,我们将采用几种简单的方式,这将有助于扩展历史数据的深度,以便运用 Go-Explore 算法进行模型训练。
在优化先前创建的算法之前,有必要分析其瓶颈。
第一步是更改 Cell 结构中的常量。 该结构存储系统的单独状态,和所采取的路径。 由于技术原因,我们被迫在此结构中仅使用静态数组。 随着模型训练周期的增加,达成所述状态的路径规模也会增加。 我们已经创建了一个常量,指示数组的大小。 现在我们应更改这个常量的值,以便有足够的空间来记录代理者从训练期开始到结束的整体路径。
为了判定常量的值,我们要用到简单的数学。 平均而言,一个日历月份包含 21-22 个工作日。 为避免出错,我们取工作日的最大值 — 22。 4 个月内将有 88 个工作日。
在测试模型时,我们将取用 H1 时间帧。 一个工作日有 24 小时。 因此,为了训练模型,我们需要一个包含 2112 个元素(88 * 24)的缓冲区。 这些计算考虑到可能的最大值,并略微超过实际的柱线数量,这令我们不必担心遭遇数组大小超界的严重错误。 不过,若依据包括周末在内的报价(例如,加密货币)上进行训练时,应采用日历日期来计算缓冲区大小,同时考虑到整个训练周期,和金融产品报价的特征。
#define Buffer_Size 2112
第二个瓶颈是在保存样本之前进行的排序。 实践表明,数据排序要比遍历历史数据和收集这些状态花费更多时间。 随着训练周期的增加,需要排序的数据量也会有所增加。 故此,我们决定放弃数据排序。 最终结果,Faza1.mq5 智能程序中的 OnTesterDeinit 函数得以改成以下形式:
//+------------------------------------------------------------------+ //| TesterDeinit function | //+------------------------------------------------------------------+ void OnTesterDeinit() { //--- int total = ArraySize(Total); printf("total %d", total); Print("Saving..."); SaveTotalBase(); Print("Saved"); }
在测试过程中,发现 EA 经常开立多笔仓位,并长期持仓。 我们希望通过采取整体方式来解决这个问题,并对样本收集 EA 进行一些更改。
其中一项变化涉及薪酬的定义。 以前,我们曾用净值变化作为奖励。 这种方式允许模型参考累计变化和未记录利润、惩罚性回撤、以及盈利仓位的利润积累的鼓励。 然而,这种方式限制了获利的可能性。 我们不想放弃采用净值的益处,但我们也想增加获利回吐奖励。
我们找到了一个折衷的方案,涉及采用净值和账户余额变化的算术平均值。 当持一笔持仓积累了利润或亏损时,净值会发生变化,但账户余额保持不变。 代理者获得的奖励或惩罚等于净值变化的一半。 当利润或损失被记录时,净值不会改变,但累计金额会在账户余额上反映出来。 代理者收到待结算的另一半奖励或惩罚。 因此,代理者对获利了结更感兴趣,而不太倾向于捂仓。
Base[action_count - 1].value = ( Base[action_count - 1].state[241] - state[241] + Base[action_count - 1].state[240] - state[240] ) / 2.0f;
我们还决定限制开仓的最大交易量,以减少其数量。 在创建样本和测试模型时,我们为每笔交易采用了固定的最小交易量。 因此,对开仓的交易量进行限制,与限制开仓的数量完全雷同。 不过,当描述系统的当前状态时,我们会收集有关持仓数量,和累计盈亏的信息。 为了避免额外的计算,我们采用开仓交易量来限制最大交易量。 我们将开仓的最大可能交易量的数值移到外部变量之中,这令我们能够取不同的数值进行实验。
input double MaxPosition = 0.1;
我们限制最大开仓交易量的最终目标是减少账户中的开仓数量,避免盈亏锁定时积累交易。 为此,我们分别检查多头和空头交易的限额,而并不考虑它们的差别。
要注意的重点是,我们并未明确指定模型的约束。 取而代之,我们在创建训练模型的样本阶段,会针对最大开仓交易量施加限制。 接下来,我们用这些样本来训练模型,且基于得到的样本自行构建其策略。 这种方式令模型能够适应不断变化的市场情况,并选择最有效的行动。
不过,值得考虑的是,在我们生成一个开仓动作的情况下,若由于施加的限制而无法执行时,系统的后续状态和奖励将与生成的动作不对应。 为了解决这个问题,我们在样本数据库中保存了一个与期望(无交易)相对应的动作,以防生成的动作未能执行。 这就确保了行动和奖励之间的对应关系,并确保正确训练模型。
switch(act) { case 0: if(buy_value >= MaxPosition || !Trade.Buy(Symb.LotsMin(), Symb.Name())) act = 3; break; case 1: if(sell_value >= MaxPosition || !Trade.Sell(Symb.LotsMin(), Symb.Name())) act = 3; break; case 2: for(int i = PositionsTotal() - 1; i >= 0; i--) if(PositionGetSymbol(i) == Symb.Name()) if(!Trade.PositionClose(PositionGetInteger(POSITION_IDENTIFIER))) { act = 3; break; } break; }
由于我们是在高风险的市场交易条件下运作,我们的任务不仅是赚取利润,而且还要尽量减少可能的损失。 为此,我们在模型中添加了持仓最长时间的限制。
此限制是一个整数型外部变量,以最大柱线数量为单位指定持仓时限。
input int MaxLifeTime = 48;
我们判定最久持仓的生存期,当达到边界值时,我们创建一个动作强制将所有持仓了结。
这是必要的,如此我们就不会持仓超期,否则可能导致巨大的损失。 在收集有关当前账户状态和持仓的信息时,我们会参考该限制,以免超过最长持有时间。
int total = PositionsTotal(); datetime time_current = TimeCurrent(); int first_order = 0; for(int i = 0; i < total; i++) { if(PositionGetSymbol(i) != Symb.Name()) continue; switch((int)PositionGetInteger(POSITION_TYPE)) { case POSITION_TYPE_BUY: buy_value += PositionGetDouble(POSITION_VOLUME); buy_profit += PositionGetDouble(POSITION_PROFIT); break; case POSITION_TYPE_SELL: sell_value += PositionGetDouble(POSITION_VOLUME); sell_profit += PositionGetDouble(POSITION_PROFIT); break; } first_order = MathMax((int)(PositionGetInteger(POSITION_TIME) - time_current) / PeriodSeconds(TimeFrame), first_order); }
不过,如果我们允许超过此限制,则应采取适当的措施。 在这种情况下,我们不是简单地在时间到期后平仓一笔,而是特指动作会将所有持仓了结。 这令我们能够在完成的动作、新状态和奖励之间保持对应关系,这对于模型的正确操作很重要。
int act = (first_order < MaxLifeTime ? SampleAction(4) : 2);
因此,使用持仓最长时间限制是我们模型中的另一种机制,可以帮助我们在不确定的市场条件下控制风险,并取得更稳定的结果。
我们讲述了在模型测试过程中,根据辨别出的缺点优化算法的方式。 现在,我们转移到基于扩展历史数据来训练模型。 我们研究一下将大型训练集合拆分为较小的部分,并依据每个部分来训练代理者的可能性。 我们可以假设,如果一个算法在很短的周期内运行良好,那么它也可以在较长的时间内运行良好。 因此,我们可以采用这种方式来改进大数据的模型训练。
这种方式令模型能够更有效地捕捉市场趋势,并增加其对外部因素变化的抵抗力。 当利用该模型在真实市场交易时,这一点尤其重要,因为在真实市场中,预测趋势方向的变化至关重要。 此外,这种方法令模型能够更有效地利用所有可用数据,而不仅仅是最新的观测结果,这反过来又提高了预测的品质。
需注意的重点是,将训练集合划分为更小的时间周期时,应考虑到数据的时间顺序,以避免数据重叠和预测偏差。 还必须考虑到,当将数据划分为更小的片段时,每个片段中可用于训练的数据量会更少,这可能会导致模型的预测精度降低。
因此,将训练集合拆分为更小的时间片段是优化算法的有效方式,可以显著提高模型的预测品质。
当将训练集合划分为更小的子集时,我们面对的是开发一个通体策略,以便我们能够成功地遍历整个训练集合。 为此,我们可以把随机动作抽样和定向分步训练结合使用,这将有助于我们找到最成功和最有利可图的策略。
这个思路是按顺序遍历小子集,在每个子集中使用动作随机抽样。 然后,我们选择最有利可图的验算,并将它们用作下一子集的起点。 如此,我们按顺序遍历整个训练集合,积累有利可图的策略样本。
这种方式结合了看似互逆的思路:随机抽样和定向训练。 使用随机抽样,我们是在探索环境;训练样本的定向推进则有助于我们找到最成功的策略。 结果就是,我们能为我们的代理者获得更普遍和更有利可图的策略。
一般来说,将随机抽样和定向训练相结合,可以让我们尽享随机性和已经积累的成功行动的经验,依据所传递的训练样本获得最优策略。
为了实现这种方式,我们需引入 3 个外部变量:
- MinStartSteps — 采样开始前的最小步数
- MaxSteps - 最大采样步数(序列大小)
- MinProfit - 保存到样本数据库的最低利润。
input int MinStartSteps = 96; input int MaxSteps = 120; input double MinProfit = 10;
在讨论算法优化时,我们发现在保存之前进行样本排序效率低下。 取而代之,我们决定采用 MinProfit 变量作为最低利润,判定是否将样本包含在数据库。 这令我们能够为后续抽样的起点优选样本。 此外,我们利用 MinStartSteps 变量来设置样本中起点所需的最小步骤。 这令我们能够避免在采样过程中陷入中间步骤,并继续下一子集。
我们还用到 MaxSteps 变量,其判定了最大子集长度。 一旦超过此数值,采样将不再有效,且我们需要保存行进的路径。 以这种方式,我们可以更有效地利用资源,并加快训练速度。
在 Faza1.mq5 EA 的 OnInit 方法中,加载以前创建的样本数据库后,我们首先选择满足完成步骤所需的样本。
if(LoadTotalBase()) { int total = ArraySize(Total); Cell temp[]; ArrayResize(temp, total); int count = 0; for(int i = 0; i < total; i++) if(Total[i].total_actions >= MinStartSteps) { temp[count] = Total[i]; count++; }
之后,从所选样本中随机抽样选择一个样本。 我们将用这个随机选择的样本作为起始采样点。
if(count > 0) { count = (int)(((double)(MathRand() * MathRand()) / MathPow(32768.0, 2.0)) * (count - 1)); StartCell = temp[count]; } else { count = (int)(((double)(MathRand() * MathRand()) / MathPow(32768.0, 2.0)) * (total - 1)); StartCell = Total[count]; } }
在 EA 的 OnTick 方法中,我们首先无条件地执行整条路径,直到我们到达子集的起点。
void OnTick() { //--- if(!IsNewBar()) return; bar++; if(bar < StartCell.total_actions) { switch(StartCell.actions[bar]) { case 0: Trade.Buy(Symb.LotsMin(), Symb.Name()); break; case 1: Trade.Sell(Symb.LotsMin(), Symb.Name()); break; case 2: for(int i = PositionsTotal() - 1; i >= 0; i--) if(PositionGetSymbol(i) == Symb.Name()) Trade.PositionClose(PositionGetInteger(POSITION_IDENTIFIER)); break; } return; }
只有在到达我们的子集开头后,我们才会转进到动作采样操作。 与此同时,我们控制随机操作执行的数量。 它们的数量不应超过最大子集长度。
如果达到最大步数,我们首先生成一个操作来把所有仓位平仓。
int act = (action_count < MaxSteps || first_order < MaxLifeTime ? SampleAction(4) : 2);
这次转进之后,我们着手结束 EA 的工作。
if(action_count > MaxSteps) ExpertRemove();
在策略测试器中完成验算后,我们检查验算后获得的利润规模。 如果满足达到最小盈利阈值的条件,我们将数据添加到样本数据库之中。
//+------------------------------------------------------------------+ //| Tester function | //+------------------------------------------------------------------+ double OnTester() { //--- double ret = 0.0; //--- double profit = TesterStatistics(STAT_PROFIT); action_count--; if(profit >= MinProfit) FrameAdd(MQLInfoString(MQL_PROGRAM_NAME), action_count, profit, Base); //--- return(ret); }
在此,我们仅提供并解释 EA 代码的微小更改,大部分在上一篇文章中已有详述。 完整的 EA 代码可在附件中找到。
3. 测试
如同上一篇文章,我们为训练模型来收集 EURUSD H1 上的历史数据样本。 不过,这次我们将取 2023 年 4 个月的历史数据。
为了最有效地探索环境,必须在样本采集过程中采用各种外部参数值。 在这种情况下,我们将把这些参数作为优化参数,这样能令我们在每次验算时更改它们的数值。
为了开始优化过程,我们将选择两个参数:MaxSteps 和 MaxLifeTime。 第一个参数确定子集的最大长度,超距后,所收集样本无效。 第二个参数指示在一个子集中持仓的最长期限。 在收集样本的过程中采用这些参数的不同数值,我们可以尽可能全面地研究环境。
例如,通过采用 MaxSteps 和 MaxLifeTime 的不同数值,我们能够收集到不同持仓持续时间和期限的样本。 这令我们能够获得环境中也许会浮现的更广泛状况的样本。 以这种方式,我们能创建一个更通用、更有效的训练模型,该模型将参考许多不同的场景。
我们设置利润值的阈值接近 0。 毕竟,这是首次验算,我们只需要赚取微薄的利润。
作为优化过程的首次验算,我们看到在 2023 年 1 月的前 2 周内,有若干次成功的验算,达到 46 美元的利润。 此类验算的利润系数达到 1.55
在运行优化之前,我们将对参数进行一些更改。 为确保按不同的时间间隔收集样本,我们将在采样开始之前往优化变量里加上最小步数。 此变量的值将在 1 到 3 周之间变化,增量按周为单位。 此外,我们把利润阈值提高到 40 美元,来提升所获得的结果。
根据第二次优化的结果,我们看到 2023 年 1 月的利润增加到 84 美元。 尽管利润因子下降到 1.38。
尽管如此,我们看到为优化样本采集过程付出的努力开始取得成果。 尽管我们还没有达成最终的成功,然事件的大趋势与我们的目标和期望是一致的。
我们在 2023 年 1 月的第二周开始采样之前增加最小步骤数,并执行另一次优化过程。 这一次,我们把最低盈利能力提高到 80 美元。 毕竟,我们努力寻找最有利可图的策略。
正如我们预期的那样,由于随后对样本收集过程的优化,我们达成更高的盈利能力。 最成功的验算总收入已增加到 125 美元。 同时,盈利因子略有下降,为 1.36,这仍然意味着盈利超过成本。 重点注意的是,这种盈利能力的提高是通过改进样本采集过程达成的,我们可以对其效率充满信心。 不过,请记住,训练尚未完成,我们将继续进行。
我们继续迭代在策略测试器的优化模式下收集样本,依次改变抽样起点,和利润阈值。 这种方式令我们能够在遍历整个训练集获得若干次成功验算的样本。 其中最有利可图的产生了 281 美元的收入,盈利系数为 1.5。 这些结果确认了我们优化案例收集流程的策略具有积极效果,有助于达成更高的利润门槛。 不过,我们明白这个过程并不完整,需要进一步优化和改进。
一旦收集样本的过程完成后,我们转进到依据所获数据运用 Go-Explore 算法训练模型。 然后,我们利用强化训练方法重新训练模型,以进一步提升其性能。
为了检查训练模型的品质和成效,我们依据训练和测试样本对其进行了测试。 重点注意的是,我们的模型能够自 2023 年 5 月第一周的历史数据中获利,这些数据不包括在训练集之中,而是训练集的直接延续。
结束语
在本文中,我们研究了优化 Go-Explore 算法的简单但有效的方法,令其能够基于大数据训练模型。 我们的模型是依据 4 个月的历史数据上训练的,但感谢采用的优化方法,Go-Explore 算法可在更长的时间周期内训练模型。 我们还依据训练和测试样本进行了模型测试,确认了其高效率和高品质。
总体而言,Go-Explore 算法为基于大数据训练模型开辟了新的可能性,对于人工智能领域额各种应用,其已成为有力工具。
然而,重点是要记住,金融市场是极具动态的,且易受突发变化影响,因此即使是最优质的模型也不能保证 100% 的成功。 因此,我们必须不断监测市场的变化,并相应地调整我们的模型。
链接
- Go-Explore:解决艰难探索难题的新途径
- 神经网络变得轻松(第三十五部分):内在好奇心模块
- 神经网络变得轻松(第三十六部分):关系强化学习
- 神经网络变得轻松(第三十七部分):分散关注度
- 神经网络变得轻松(第三十八部分):凭借分歧进行自我监督探索
- 神经网络变得轻松(第三十九部分):Go-Explore,一种不同的探索方式
本文中用到的程序
# | 名称 | 类型 | 说明 |
---|---|---|---|
1 | Faza1.mq5 | 智能交易系统 | 第一阶段 EA |
2 | Faza2.mql5 | 智能交易系统 | 第二阶段 EA |
3 | GE-lerning.mq5 | 智能交易系统 | 优调 EA 的政策 |
4 | Cell.mqh | 类库 | 系统状态定义结构 |
5 | FQF.mqh | 类库 | 完全参数化模型的工作安排类库 |
6 | NeuroNet.mqh | 类库 | 用于创建神经网络的类库 |
7 | NeuroNet.cl | 代码库 | OpenCL 程序代码库 |
本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/12584
注意: MetaQuotes Ltd.将保留所有关于这些材料的权利。全部或部分复制或者转载这些材料将被禁止。
This article was written by a user of the site and reflects their personal views. MetaQuotes Ltd is not responsible for the accuracy of the information presented, nor for any consequences resulting from the use of the solutions, strategies or recommendations described.

大家下午好。有人成功训练过这个神经网络吗?如果有,您是如何做到的?
我收集了与文章作者相同时期(4 个月)的第一阶段数据。我得到了一个大约 1.2 GB 的 bd 文件(190 000 个特征)。然后我开始训练第二阶段。第 2 阶段的默认迭代次数为 100,000 次。我多次尝试运行第 2 阶段。我还尝试设置 1,000,000 和 10,000,000 次迭代。在所有这些尝试中,第 2 阶段显示的误差波动在 1.6 ...1.8 并且没有下降。或者增长到 0.3(使用其他 bd 文件)。运行第 3 阶段(在测试器中)时,它不会混淆交易。它只是愚蠢地打开一个交易并保持到测试时间结束。我尝试在测试器中以优化模式 运行第 3 阶段。我尝试了 200、500、1000 次。但没有任何影响。唯一的问题是,Expert Advisor 会提前或延后打开交易,并保持到测试结束。但它本身并不关闭交易,而是测试者关闭交易,因为时间到了。我还尝试将 NeuroNet.mqh 文件中的 #define lr 3.0e-4f 参数更改为 1.0e-4f 或 2.0 e-4f,但也不起作用。我到底做错了什么?
有人能解释一下你是如何训练的吗?如果可以,请详细说明。
第 3 阶段的误差是多少?
第 2 阶段迭代了多少次?
如果第 2 阶段的误差没有减少,该怎么办?
迭代到多少次时,你开始改变什么?具体改什么?
在第 3 阶段,EA 只打开交易而不尝试交易,这正常吗?在优化模式下用第 3 阶段进行训练有意义吗?